
  /* Aspekta — self-hosted variable sans (SIL OFL 1.1, see
     /hero-v2/assets/fonts/aspekta/LICENSE.txt), replacing DM Sans and
     Archivo Expanded as the site's one typeface. One variable file
     covers the whole 100-900 weight range we use (regular body copy
     through the statement/services display weights), so no separate
     @font-face per weight the way the two Google Fonts it replaces
     needed. DM Sans stays listed as the fallback in every font-family
     below rather than being removed, so a failed/blocked font load
     degrades to the previous look instead of the browser default. */
  @font-face {
    font-family: 'Aspekta Variable';
    font-weight: 100 900;
    font-display: swap;
    font-style: normal;
    src: url('https://cdn.prod.website-files.com/6aaab8aa5da02fa0b7406b4a/6aad41da541e9e7bb7608099_AspektaVF.woff2') format('woff2');
  }
  :root{
    /* PALETTE — the whole site's six colors, each with exactly one
       job. Every other color in this file (the CSS below, the JS
       configs, and the canvas/shader draw calls) reads from these
       var()s or from window.PALETTE (the matching JS mirror, defined
       at the top of the first script block) rather than a hard-coded
       hex, so the whole palette is a one-line edit in either place. */
    --color-near-black: #0B0B0F; /* dark section backgrounds, text on light */
    --color-cream: #E9E6DF;      /* light section backgrounds, text on dark */
    --color-orange: #FF4F00;     /* brand accent + anything interactive */
    --color-violet: #7A5CFF;     /* footer; situation card hover fill; also the hero blob glow, smoke-trail cursor glow (dark sections), and works-cursor — own fix, explicit request: these used to read a separate --color-teal (#00E5C3), consolidated here since that var was renamed to hold this same violet and having a var named "teal" hold purple was a trap for future edits */
    --color-yellow: #E6FF00;     /* motion only — cursor trail + doodle sparks. Never static text/backgrounds/UI. */

    --bg: var(--color-cream);
    --ink: var(--color-near-black);
    --logo: var(--color-orange);
    --blob-core: var(--color-violet);
    --blob-mid: color-mix(in srgb, var(--color-violet) 70%, var(--color-cream) 30%);
    --blob-edge: color-mix(in srgb, var(--color-violet) 40%, var(--color-cream) 60%);

    /* own fix, explicit request ("establish a deliberate top layer...
       with named constants so a future section can't outrank them"):
       the page-wide decorative pointer layer, highest to lowest.
       Every one of these is a fixed, page-wide element with no real
       DOM competition for its own z-index (no ordinary page content
       should ever need to sit at or above --z-trail-canvas) — but
       they used to be five separate magic numbers (50/55/59/60)
       scattered across unrelated CSS rules, each only cross-
       referencing the others in a comment. Named here once, in
       explicit rank order, so the relationship is enforced by the
       one place that sets them, not by four separate comments
       staying in sync by hand:
         --z-flame-cursor      (60) — the roaming flame mark itself,
                                always the true top layer when active.
         --z-team-slider-cursor (59) — team slider's own direction
                                cursor, replaces the flame cursor only
                                inside that slider (cursor:none there).
         --z-works-cursor      (55) — Selected Works' own hover dot,
                                same idea, scoped to that section.
         --z-trail-canvas      (50) — the page-wide smoke/pixel trail,
                                sits under every custom pointer above
                                but still above ordinary page content.
       New sections should never need to reach into this range at
       all — if one does, it belongs in this list, not a one-off
       z-index in that section's own CSS. */
    --z-trail-canvas: 50;
    --z-works-cursor: 55;
    --z-team-slider-cursor: 59;
    --z-flame-cursor: 60;
  }
  *{ box-sizing: border-box; }
  html, body{
    margin: 0;
    background: var(--bg);
    color: var(--ink);
    overflow-x: clip;
    font-family: 'Aspekta Variable', 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
  }

  /* ============================================================
     SITE NAV — fixed top nav, own build modeled on a reference
     site's own layout (logo far left, small text links center-right,
     "Let's talk ↗" far right — read live via DOM/CSSOM, not its
     code/fonts/images). mix-blend-mode: difference (own choice, the
     spec's own alternative to per-section color switching) is what
     keeps the white/grey text readable over both the dark and cream
     sections in one rule: a white element differenced against a
     near-black backdrop (#0B0B0F) resolves to near-white, and against
     the cream backdrop (#E9E6DF) resolves to near-black — both read
     as strong, correctly-inverted contrast automatically, including
     at every in-between scroll position, with nothing to recompute
     against section boundaries. The scroll-spy "current" state still
     needs real section-boundary tracking (see the script's own
     IntersectionObserver) — that's link *state*, not color; the
     color itself (white vs muted grey below) still differences the
     same way regardless of which section is behind it.
  ============================================================ */
  :root{
    --nav-height: 72px;
  }
  .site-nav{
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 200;
    display: flex;
    align-items: center;
    height: var(--nav-height);
    padding: 0 clamp(20px, 4vw, 48px);
    background: transparent;
    mix-blend-mode: difference;
    transition: transform .25s ease;
  }
  .site-nav.is-hidden{
    transform: translateY(-100%);
  }
  .site-nav__logo{
    display: inline-flex;
    align-items: center;
    color: #fff;
    text-decoration: none;
  }
  .site-nav__logo-icon{
    width: 28px;
    height: auto;
    opacity: 1;
    transition: opacity .3s ease;
  }
  /* hidden while the hero (with its own large flame-hand centerpiece)
     is in view — own follow-up, avoids showing the same icon twice
     at once. Toggled by the nav script's own IntersectionObserver on
     #hero; pages with no hero section (e.g. contact.html) never get
     this class, so the icon just stays visible there. */
  .site-nav__logo-icon.is-hidden-on-hero{
    opacity: 0;
  }
  /* ---- shared flame-hand icon — the same artwork/animation now
     used in two places (the nav logo above, and the hero blob
     centerpiece below), so the flicker keyframes live once here
     rather than duplicated per instance. ---- */
  .flame-hand-icon .flame{
    opacity: 0;
    animation: flameHandFlicker .6s steps(1) infinite;
  }
  @keyframes flameHandFlicker{
    0%{ opacity: 1; }
    16.667%{ opacity: 0; }
    100%{ opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce){
    .flame-hand-icon .flame{ animation: none; }
    .flame-hand-icon .flame.f0{ opacity: 1; }
  }
  .site-nav__links{
    display: flex;
    align-items: center;
    gap: 28px; /* CONFIRMED tight spacing between links, per spec */
    margin-left: auto;
    margin-right: 40px;
  }
  .site-nav__links a,
  .site-nav__cta{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 200;
    font-size: 16px; /* CONFIRMED ~16px, per spec */
    color: #fff;
    text-decoration: none; /* no underlines, per spec */
    white-space: nowrap;
    transition: color .2s ease;
  }
  .site-nav__links a:hover,
  .site-nav__cta:hover{
    color: #808080; /* fades to muted grey on hover, per spec — same value the scroll-spy "current" state below uses */
  }
  .site-nav__links a.is-current{
    color: #808080; /* the section currently in view */
  }
  .site-nav__cta{
    display: inline-flex;
    align-items: center;
    gap: 6px;
  }
  .site-nav__arrow{
    display: inline-block;
    transition: transform .2s ease;
  }
  .site-nav__cta:hover .site-nav__arrow{
    transform: translate(3px, -3px); /* nudges up-right a few px on hover, per spec */
  }
  .site-nav__logo:focus-visible,
  .site-nav__links a:focus-visible,
  .site-nav__cta:focus-visible,
  .site-nav__menu-btn:focus-visible{
    outline: 2px solid #fff;
    outline-offset: 4px;
  }
  .site-nav__menu-btn{
    display: none; /* desktop: no menu button — see the max-width override below */
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 16px;
    color: #fff;
    background: none;
    border: none;
    cursor: pointer;
    padding: 8px;
  }

  /* ---- mobile: logo + "Menu" text button only (see the max-width
     override below), opening this full-screen overlay of the same
     four links in big type — own build, not blended (solid
     near-black ground instead), so it stays legible full-bleed
     regardless of whatever's scrolled behind it. ---- */
  .site-nav__mobile{
    position: fixed;
    inset: 0;
    z-index: 199;
    background: var(--color-near-black);
    display: flex;
    flex-direction: column;
    padding: calc(64px + 24px) 20px 32px;
    opacity: 0;
    visibility: hidden;
    transition: opacity .3s ease;
  }
  .site-nav__mobile.is-open{
    opacity: 1;
    visibility: visible;
  }
  /* own reuse of the mobile-design spec (07-menu-open.png): links
     top-anchored with a divider above the first and below every link
     (not vertically centered, per that mockup), a fixed "Let's talk"
     CTA pinned near the bottom via margin-top:auto on the flex column. */
  .site-nav__mobile-links{
    display: flex;
    flex-direction: column;
    border-top: 1px solid color-mix(in srgb, var(--color-cream) 20%, transparent);
  }
  .site-nav__mobile a{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: clamp(32px, 12vw, 56px); /* big type, per spec */
    color: var(--color-cream);
    text-decoration: none;
    padding: 20px 0;
    border-bottom: 1px solid color-mix(in srgb, var(--color-cream) 20%, transparent);
    min-height: 44px;
  }
  .site-nav__mobile a:hover,
  .site-nav__mobile a:focus-visible{
    color: var(--color-orange);
  }
  .site-nav__mobile-cta{
    margin-top: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    background: var(--color-orange);
    color: var(--color-near-black) !important;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 18px;
    text-decoration: none;
    border-radius: 999px;
    padding: 18px 24px;
    min-height: 44px;
  }

  @media (max-width: 767px){
    .site-nav__links, .site-nav__cta{ display: none; }
    /* own fix: .site-nav__links was the flex child carrying
       margin-left:auto (the spacer that pushes everything after the
       logo to the right edge) — hiding it above also removed that
       spacer, so the menu button landed right next to the logo
       instead of at the right edge. Moving margin-left:auto onto the
       menu button itself keeps "logo left, Menu right" regardless of
       which siblings are hidden. */
    .site-nav__menu-btn{ display: inline-block; margin-left: auto; min-width: 44px; min-height: 44px; }
    /* own fix, per real-device report: mix-blend-mode: difference (see
       the base .site-nav rule) was designed for a full-width desktop
       bar crossing many background colors at once — on mobile, with
       content scrolling directly underneath a transparent bar, it read
       as broken (logo/"Menu" turning blue/green over orange/violet
       sections) and let images/doodles collide visually with the nav
       text. Solid ground + no blend mode fixes both at once: legible
       regardless of what's behind it. Height trimmed to spec's ~64px
       (was --nav-height's 72px, shared with desktop) via a local
       override rather than changing the shared var, so desktop is
       untouched. */
    .site-nav{
      background: #0B0B0F;
      mix-blend-mode: normal;
      height: 64px;
    }
    .site-nav__logo, .site-nav__menu-btn{ color: var(--color-cream); }
    .site-nav__logo-icon{ color: var(--color-cream); }

    /* ---- per-section nav theming — own follow-up, per explicit
       request: a flat #0B0B0F bar (above) doesn't match the design's
       own per-section nav (cream over cream sections, near-black over
       dark ones, violet over the footer). The script's own MOBILE NAV
       THEME block (further down) samples whichever section currently
       sits just below the nav (via elementFromPoint against each
       section's own data-nav-theme attribute — see the markup) and
       sets data-theme HERE, on .site-nav itself — not to be confused
       with the various SECTIONS' own data-theme attribute, which is
       a different, pre-existing thing (light/dark body-copy theming,
       unrelated to the nav). Defaults to the dark bar above until the
       script's first run (page-load, before any scroll) sets the
       real one, so there's never an unstyled flash. */
    .site-nav[data-theme="cream"]{
      background: var(--color-cream);
      border-bottom: 1px solid color-mix(in srgb, var(--color-near-black) 12%, transparent); /* the "thin rule" per spec — only needed when the bar is the same color as the section above/below it */
    }
    .site-nav[data-theme="cream"] .site-nav__logo,
    .site-nav[data-theme="cream"] .site-nav__menu-btn,
    .site-nav[data-theme="cream"] .site-nav__logo-icon{
      color: var(--color-near-black);
    }
    .site-nav[data-theme="dark"]{
      background: #0B0B0F;
      border-bottom: none;
    }
    .site-nav[data-theme="dark"] .site-nav__logo,
    .site-nav[data-theme="dark"] .site-nav__menu-btn,
    .site-nav[data-theme="dark"] .site-nav__logo-icon{
      color: var(--color-cream);
    }
    .site-nav[data-theme="violet"]{
      background: var(--color-violet);
      border-bottom: none;
    }
    .site-nav[data-theme="violet"] .site-nav__logo,
    .site-nav[data-theme="violet"] .site-nav__menu-btn,
    .site-nav[data-theme="violet"] .site-nav__logo-icon{
      color: var(--color-cream);
    }
  }

  /* invisible scroll targets for the two sections whose own id is
     already used elsewhere in this file's own script (see the HTML
     comment at each one) — scroll-margin-top makes each the right
     landing spot even for a plain, JS-free hash jump, not just the
     script's own smooth-scroll-with-offset. */
  .nav-anchor{
    display: block;
    height: 0;
    scroll-margin-top: var(--nav-height);
  }
  @media (prefers-reduced-motion: reduce){
    .site-nav{ transition: none; } /* no hide/show slide, per spec — the script's own scroll handler skips hiding it entirely under reduced motion too, so this is belt-and-suspenders */
    .site-nav__mobile{ transition: opacity .15s ease; } /* a quick fade only — no motion beyond that */
  }

  /* ============================================================
     HERO SHELL + HERO PIN — the hero is now a position:sticky
     child of a taller wrapper (#heroPinWrapper), reserving
     FLAME_MASK_CONFIG.pinExtraVh (90vh) of EXTRA scroll distance
     beyond the hero's own height. Own choice, explicitly preferred
     over a scroll-locking/JS-driven alternative: native scroll
     (wheel, trackpad momentum, touch, keyboard) never stops working
     and nothing here ever calls preventDefault — the hero simply
     stays visually pinned for as long as the wrapper's extra height
     lasts, and heroScrollProgress() (script, further down) reads
     that same scroll position directly, so the flame-mask's growth
     is a pure function of scroll position, never a triggered
     animation with its own state to desync from a fast flick.
     Once the user has scrolled through the full extra distance, the
     wrapper's own bottom edge catches up to the hero's sticky top
     and releases it — normal, native scrolling into the section
     below, no special-case hand-off logic needed. Scrolling back up
     un-sticks and un-grows through the exact same math in reverse.
  ============================================================ */
  .hero-pin-wrapper{
    position: relative; /* NOT overflow:hidden — that would break the sticky child's own stickiness against this container */
    height: calc(100svh + 90vh);
  }
  /* REDUCED MOTION: no pin at all — the wrapper collapses to the
     hero's own natural height (auto, since .hero goes back to
     position:relative right below) and scrolling runs straight
     through, per explicit spec. */
  .reduce-motion .hero-pin-wrapper{ height: auto; }
  .hero{
    position: sticky;
    top: 0;
    width: 100%;
    height: 100svh;
    min-height: 620px;
    overflow: hidden;
    background: var(--hero-bg, var(--bg));
  }
  .reduce-motion .hero{ position: relative; }
  /* own follow-up, per explicit request: on mobile the fixed nav sits
     ON TOP of the hero (nav is position:fixed, hero fills the whole
     100svh underneath it), which is what let it cover the tagline —
     margin-top pushes the hero's own box (and everything absolute
     inside it, since percentages below are relative to ITS OWN now-
     shorter height) entirely below the nav instead, and the height
     is trimmed by the same amount so the total (nav + hero) still
     equals exactly one screen — no dead space at the bottom from
     double-counting the nav's own height twice. 64px matches the
     nav's own mobile height exactly (see .site-nav's own max-width
     override above). top:64px (rather than 0) sticks the hero at the
     same 64px-below-nav position it already naturally sits at before
     any scrolling — margin-top provides that initial offset, top
     provides the matching stuck position, so there's no jump at the
     moment it catches. */
  @media (max-width: 767px){
    .hero{
      margin-top: 64px;
      top: 64px;
      height: calc(100svh - 64px);
    }
  }
  /* Every full-hero-cover child below (blob, headline layers, pixel
     trail, logo/nav/tagline/scroll-cue) is deliberately
     position:absolute against THIS box, not position:fixed against
     the viewport. Since the hero is exactly one viewport tall, both
     look identical while the hero is the only thing on screen — the
     difference only shows once there's a section below it to scroll
     into: fixed would stay pinned over that section forever, while
     absolute correctly scrolls away with the hero. The flame cursor
     is the one deliberate exception (still position:fixed) — it's
     meant to keep tracking the mouse across the hero AND the
     statement section below, not just inside this box. */

  /* ============================================================
     DOT GRID — cherry-bomb grid (traced from the live site's
     "Our Work" bomb icon), confined to the left/upper-mid region
     only (matches report: x 0-47%, y 19-77%). Each bomb ~39px tall
     (base 30px, sized up 30% per explicit request) on a ~62px grid,
     every other row offset by a half-step — see the tile's own two
     <use> placements below.
  ============================================================ */
  .dot-grid{
    position: absolute;
    left: 0;
    top: 19%;
    width: 47%;
    height: 58%;
    background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2262%22%20height%3D%22124%22%20viewBox%3D%220%200%2062%20124%22%3E%3Cdefs%3E%3Cpath%20id%3D%22cb%22%20fill-rule%3D%22evenodd%22%20d%3D%22M90.54%2C214.46%20C109.24%2C209.64%20125.16%2C196.63%20133%2C179.77%20C137.43%2C170.27%20139.1%2C162.63%20139.08%2C152%20C139.04%2C136.85%20134.43%2C123.5%20125.38%2C112.33%20C123.52%2C110.04%20122%2C107.67%20122%2C107.06%20C122%2C106.45%20123.59%2C102.8%20125.52%2C98.95%20C128.25%2C93.55%20128.82%2C91.53%20128.05%2C90.09%20C127.17%2C88.46%20119.89%2C83.14%20114.7%2C80.34%20C113.16%2C79.51%20113.67%2C78.97%20118.2%2C76.55%20C133.23%2C68.57%20137.01%2C64.3%20136.98%2C55.36%20C136.97%2C49.76%20135.87%2C47.45%20133.63%2C48.31%20C132.44%2C48.77%20132.1%2C50.25%20132.21%2C54.48%20C132.38%2C61.2%20130.41%2C64.38%20123.91%2C67.87%20C121.49%2C69.17%20116.91%2C71.78%20113.74%2C73.68%20L107.99%2C77.13%20L101.74%2C74.69%20C94.84%2C71.99%2088.9%2C70.82%2087.25%2C71.85%20C86.66%2C72.21%2084.58%2C76%2082.64%2C80.26%20L79.11%2C88%20L73.72%2C88%20C66.27%2C88%2054.07%2C91.3%2045.07%2C95.74%20C25.1%2C105.61%2011.03%2C128.86%2011.03%2C152%20C11.03%2C180.92%2031.11%2C206.81%2059.5%2C214.51%20C66.41%2C216.39%2083.19%2C216.36%2090.54%2C214.46%20Z%20M59%2C203.72%20C49.85%2C200.59%2043.69%2C196.72%2036.88%2C189.8%20C27.77%2C180.55%2021.27%2C164.96%2022.16%2C154.5%20C22.44%2C151.16%2022.9%2C150.44%2024.89%2C150.16%20C27.8%2C149.75%2028.96%2C151.4%2029%2C156%20C29.06%2C163.98%2033.49%2C175.1%2039.61%2C182.66%20C46.93%2C191.68%2057.24%2C197.19%2070.26%2C199.04%20C78.32%2C200.18%2079.49%2C200.87%2078.53%2C203.9%20C77.95%2C205.74%2077.1%2C206%2071.68%2C205.97%20C67.97%2C205.96%2062.9%2C205.06%2059%2C203.72%20Z%20M113%2C60.01%20C113%2C58.83%20113.83%2C58.02%20115.25%2C57.82%20C116.49%2C57.64%20117.5%2C56.83%20117.5%2C56%20C117.5%2C55.17%20116.49%2C54.36%20115.25%2C54.18%20C113.6%2C53.95%20113%2C53.19%20113%2C51.35%20C113%2C48.31%20110.65%2C48.66%20110.19%2C51.77%20C110%2C53.05%20109.02%2C54%20107.69%2C54.19%20C104.81%2C54.6%20104.86%2C57.41%20107.75%2C57.82%20C109.11%2C58.01%20110%2C58.84%20110%2C59.9%20C110%2C60.87%20110.29%2C61.95%20110.64%2C62.31%20C111.67%2C63.33%20113%2C62.04%20113%2C60.01%20Z%20M158.82%2C46.17%20C159.05%2C44.6%20159.84%2C44%20161.65%2C44%20C164.69%2C44%20164.34%2C41.65%20161.23%2C41.19%20C159.95%2C41%20159%2C40.02%20158.81%2C38.69%20C158.64%2C37.48%20157.82%2C36.5%20157%2C36.5%20C156.18%2C36.5%20155.36%2C37.48%20155.19%2C38.69%20C155%2C40.02%20154.05%2C41%20152.77%2C41.19%20C149.61%2C41.65%20149.32%2C44%20152.42%2C44%20C154.26%2C44%20155%2C44.53%20155%2C45.83%20C155%2C49.63%20158.27%2C49.92%20158.82%2C46.17%20Z%20M140.99%2C42.16%20C144.79%2C38.36%20145.8%2C31.81%20143.59%2C25.31%20C141.62%2C19.52%20135.98%2C10%20134.52%2C10%20C131.97%2C10%20123.97%2C26.99%20124.02%2C32.28%20C124.05%2C35.51%20127.51%2C42.62%20129.66%2C43.87%20C133.17%2C45.91%20137.96%2C45.19%20140.99%2C42.16%20Z%20M131.55%2C41.64%20C128.63%2C38.72%20128.3%2C35.45%20130.6%2C32.17%20C134.17%2C27.07%20141.65%2C32.47%20139.47%2C38.57%20C138.94%2C40.05%20137.51%2C41.93%20136.3%2C42.73%20C134.28%2C44.06%20133.88%2C43.97%20131.55%2C41.64%20Z%22%2F%3E%3C%2Fdefs%3E%3Cuse%20href%3D%22%23cb%22%20fill%3D%22%23CEC9C0%22%20transform%3D%22translate%280.239%2C11.500%29%20scale%280.169565%29%22%2F%3E%3Cuse%20href%3D%22%23cb%22%20fill%3D%22%23CEC9C0%22%20transform%3D%22translate%2831.239%2C73.500%29%20scale%280.169565%29%22%2F%3E%3C%2Fsvg%3E");
    background-size: 62px 124px;
    background-repeat: repeat;
    z-index: 0;
    pointer-events: none;
  }
  /* own mobile fix: the tile is a fixed 62x124px background-image,
     so on a much narrower hero the same-size bombs read as too few
     and too large relative to the shrunken container — scaling
     background-size down scales the whole embedded SVG (bombs +
     spacing) together, same proportions, just smaller, and it stays
     behind the headline (z-index:0 is untouched). */
  @media (max-width: 767px){
    /* own follow-up, per explicit request: the previous round's box
       (47%/58%, inherited from desktop's own confined placement) plus
       a mask-fade was a compromise around the tile's hard edge
       cutting bombs in half — this round wants the pattern covering
       the WHOLE area behind the headline instead of a corner of it,
       which sidesteps that edge problem directly (widened to the
       full hero, so there's no nearby edge for a bomb to straddle)
       and drops the mask entirely, no more fade. Grid step bumped
       from 38px to 46px (bombs ~22px, same 62:124 tile aspect scaled
       proportionally so the two offset placements inside stay
       correct) and opacity to the spec's own ~0.5, still behind the
       headline (z-index:0, untouched). */
    .dot-grid{
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      background-size: 46px 92px;
      opacity: 0.5;
      -webkit-mask-image: none;
      mask-image: none;
    }
  }

  /* ============================================================
     BLOB — static, no idle drift, no cursor-follow. Plain CSS
     radial-gradient + blur, cropped by the bottom-right corner.

     Own fix, per explicit report: the hero already clips this via
     .hero's own overflow:hidden, so it can't literally bleed past
     the section box — the actual complaint was that it stayed at
     full static teal intensity while SEAM_CONFIG (below, near the
     end of the file) darkens the hero's own --hero-bg right at the
     boundary, so right at the seam it read as a hard bright shape
     against the next (already-dark) section instead of fading out
     with its own backdrop. --hero-fade-out is the same eased 0→1
     crossfade progress SEAM_CONFIG already computes for the hero
     boundary (see its own comment there) — reusing it here instead
     of a second scroll listener keeps this in lockstep with the bg
     transition by construction. Falls back to fully opaque (the
     resting state, and the prefers-reduced-motion path where that
     script never runs and the var is never set).
  ============================================================ */
  .blob{
    position: absolute;
    right: -8%;
    bottom: -16%;
    width: 52vw;
    height: 52vw;
    max-width: 800px;
    max-height: 800px;
    border-radius: 50%;
    background: radial-gradient(circle at 58% 38%,
                var(--blob-core) 0%, var(--blob-mid) 30%, var(--blob-edge) 48%, transparent 68%);
    filter: blur(42px);
    opacity: calc(1 - var(--hero-fade-out, 0));
    z-index: 1;
    pointer-events: none;
  }
  /* ---- flame-hand centerpiece, sitting right on the blob's own
     radial-gradient hotspot (58% 38%, matching .blob's own
     background-position above, not a plain 50/50 center) — own
     follow-up placement, sized big and unblurred (the blob's own
     blur(42px) doesn't apply here since this is a sibling, not a
     child, of .blob). ---- */
  .hero-blob-icon{
    position: absolute;
    right: -8%;
    bottom: -16%;
    width: 52vw;
    height: 52vw;
    max-width: 800px;
    max-height: 800px;
    z-index: 2;
    pointer-events: none;
    opacity: calc(1 - var(--hero-fade-out, 0));
    transition: opacity .15s ease-out;
  }
  /* Hero flame-cursor suppression: briefly removed page-wide (data-
     flame-cursor="off" attribute + the old generic CURSOR_SUPPRESS_
     SELECTOR gone), then explicitly reconfirmed for the hero
     specifically — the small roaming flame mark stays at opacity 0
     over #hero, now via a scoped closest('#hero') check in the FLAME
     CURSOR script's handleMove (.flame-cursor.is-hero-suppressed),
     not the removed generic mechanism. Reason unchanged: the hero's
     own large aperture (below) already follows the cursor here, and
     the small roaming mark visually doubles up with it. */
  .hero-blob-icon .flame-hand-icon{
    position: absolute;
    /* own fix, per explicit request: was top:58%, which left only ~17px
       of clearance between the icon's bottom edge and the hero's own
       bottom edge (measured on a 1728x931 viewport) — tighter than the
       ~28px .scroll-cue gets, and visually crowded the corner. Pulled
       up so the icon's bottom lands almost exactly at .scroll-cue's own
       top (roughly 858px vs 855px on that same viewport), giving it
       real breathing room below while keeping the two visually level
       with each other instead of the icon hanging lower. Desktop only —
       mobile's own top:47.25% override below is unaffected. */
    top: 51%;
    left: 58%;
    transform: translate(-50%, -50%);
    width: clamp(150px, 16.25vw, 250px); /* ~2.5x a small reference render, per explicit request */
    height: auto;
    color: var(--color-cream);
  }

  /* ============================================================
     FLAME MASK — an inverted-colorway duplicate of the hero (ink
     ground, cream headline, dark-treatment dot-grid), sitting on
     top of the base hero and masked to the flame-hand silhouette
     via mask-image/-webkit-mask-image. mask-position/mask-size are
     driven per-frame by the FLAME MASK script (own JS module, see
     the script section of this file) — cursor-follow at rest,
     scroll-driven growth once the user starts scrolling, until it
     covers the viewport and hands off to the section below.

     Own fix, reverted (a separate stroked-line overlay was tried
     briefly — see git history — but a plain hole reads as genuine
     line work once the aperture itself is small enough that a
     crease's own width stays a thin sliver rather than a wide gap
     chopping letters into fragments; restWidth was sized down for
     exactly that). Interior detail (finger separations, palm
     crease, thumb edge, the flame's own inner shape) is carved OUT
     of this one mask as a hole, same technique as the outer
     silhouette itself — both are just alpha in flame-hand-mask.png,
     see the script's own MASK SHAPE comment for how that PNG is
     built.
  ============================================================ */
  .hero-reveal{
    position: absolute;
    inset: 0;
    z-index: 25; /* above every other hero layer (tagline/scroll-cue at 20 is the next-highest) and below the pixel-trail canvas (50) + flame cursor, so both stay visible over it */
    background: var(--color-near-black);
    pointer-events: none;
    overflow: hidden;
    mask-repeat: no-repeat;
    -webkit-mask-repeat: no-repeat;
    mask-image: url("https://cdn.prod.website-files.com/6aaab8aa5da02fa0b7406b4a/6ab4924a3fc9490cf2ae1f98_flame-hand-mask.png");
    -webkit-mask-image: url("https://cdn.prod.website-files.com/6aaab8aa5da02fa0b7406b4a/6ab4924a3fc9490cf2ae1f98_flame-hand-mask.png");
    mask-size: 320px 320px;
    -webkit-mask-size: 320px 320px;
    mask-position: 68% 56%;
    -webkit-mask-position: 68% 56%;
    /* own follow-up, explicit request: hidden by default — this used
       to always render at its resting mask-position/size from the
       very first frame, simultaneously visible with .hero-blob-icon's
       own cream corner mark (the actual bug report: "both on screen
       at similar scale, reads as a duplicate"). Opacity is now driven
       per-frame by the FLAME MASK script's own CORNER HANDOFF block,
       right alongside mask-position/size — no CSS transition here,
       same reasoning those already have none (a rAF-driven value
       changing every frame doesn't need one, and one would only add
       lag on top of the eased value already being written). */
    opacity: 0;
  }
  /* no trail/cursor over this layer's own reveal content — it's a
     duplicate ground, not an independent interactive surface; the
     real, interactive base hero underneath already handles both */
  .hero-reveal, .hero-reveal *{ pointer-events: none; }
  /* same geometry as the base .dot-grid (identical background-size/
     position/repeat/mobile override, inherited via the shared class)
     — deliberate invert-to-brand-violet, confirmed per explicit
     correction (a previous pass here mistakenly recolored this to a
     dark-treatment near-black, reading the violet as an unintentional
     leftover — it wasn't: the aperture is SUPPOSED to invert the
     bombs it currently covers to brand violet, both on desktop as it
     follows the cursor and on mobile during the scroll-growth
     transition, while every bomb outside the aperture stays the base
     layer's plain grey-on-cream).

     No CSS override here on purpose — own fix, diagnosed live: a
     first attempt set fill="currentColor" in the SVG + color: var(
     --color-violet) on this rule, which LOOKED right (getComputedStyle
     reported the violet correctly) but rendered nothing — confirmed
     with a forced color:red test that stayed completely invisible.
     currentColor does not reach into an SVG loaded as a CSS
     background-image (a data-URI here is fetched as an isolated
     resource, same as an external file — the browser never connects
     its internal currentColor back to the referencing element's own
     `color`); that trick only works for genuinely inline <svg> markup.
     Fixed instead in the FLAME MASK script, right where this layer's
     dot-grid is cloned from the base — it rebuilds this same SVG's
     background-image with the fill baked in from window.PALETTE.violet
     at clone time (see "REVEAL DOT-GRID COLOR" there), so there is
     still only the one real definition of brand violet in the file
     (PALETTE.violet) — the difference from the broken attempt above is
     WHERE that single source of truth is consumed (JS string build,
     not a CSS variable an SVG resource can't see), not that there are
     two literals to keep in sync. */
  .hero-reveal h1.headline{ color: var(--color-cream); }
  /* base .line-text is opacity:0 by default (the real, visible paint
     comes from the WebGL headline canvas reading this DOM text's
     color — see HEADLINE CANVAS below) — this reveal copy has no
     canvas of its own, it's a plain static duplicate, so it needs to
     actually be visible directly. Reuses every other .line-slot/
     .line-text positioning rule as-is (same classes, same --fit
     scale on .headline-inner), so it stays pixel-aligned with the
     base headline automatically, including the same responsive
     rescaling — no separate JS measurement/sync needed for that. */
  .hero-reveal .line-text{ opacity: 1; }
  /* own fix, per explicit report: these two used to just fade their
     BASE (ink) copy's opacity down via --hero-fade-out as the mask
     grew — since that's a single scalar with no idea where the mask
     actually is, the result was a uniformly low-contrast, half-faded
     ghost of the text wherever it happened to sit, instead of a
     clean invert right at the mask edge like the headline already
     gets. Same fix as the headline: a plain cream duplicate in this
     reveal layer, reusing the exact same .tagline/.scroll-cue
     classes (so position/size/line-wrap stay pixel-identical to the
     base copy for free), with the base copy's own opacity reverted
     to a flat 1 — the two-layer mask now does 100% of the reveal
     work for these, same as it already did for the headline. */
  .hero-reveal .tagline,
  .hero-reveal .scroll-cue{ color: var(--color-cream); }

  /* own fix, per real-device report: on a short mobile hero (100svh at
     phone heights), -16% bottom / -8% right pushed most of the blob
     and flame-hand below/past the hero's own overflow:hidden edge —
     shrunk both and pulled the offsets in so the whole shape stays
     framed inside the viewport instead of getting clipped away. The
     flame-hand icon's own 150px clamp() floor was sized for the
     desktop-scale blob it sits on — same floor on this much smaller
     mobile blob made it look oversized/cropped relative to its own
     container, so it gets a smaller mobile-specific clamp too. */
  /* own follow-up, per explicit request: "fully visible... ~120px
     wide, bottom-right with ~24px clearance, teal glow behind it" —
     a literal fixed size/position instead of the previous vw-based
     negative-bleed approach (right:-4%/bottom:-6%, which is what let
     part of it clip past the hero's own overflow:hidden edge on a
     short phone screen in the first place). Solved for a 200px
     square container flush against the corner (right:0; bottom:0),
     generously sized for the blob's own blur(42px) radial-gradient
     glow to read clearly behind the icon. The icon itself isn't
     square (viewBox 430x584 — at a fixed 120px width it renders
     ~163px tall), so its horizontal and vertical placement needed
     solving separately even though both target the same ~24px
     clearance: left:58% (the base/desktop rule, unchanged) already
     lands the icon's right edge 24px from the container's right edge
     — (1 − 0.58)×200 − 60 = 24. top:58% does NOT give the matching
     24px below, since the icon is taller than it is wide (its half-
     height, ~81.5px, eats far more of the 200px box than its
     half-width, 60px, does) — confirmed by measuring only ~2.5px of
     clearance at 58%. Solving the same equation for the vertical
     axis with the icon's real half-height gives top:47.25%. */
  @media (max-width: 767px){
    .blob, .hero-blob-icon{
      width: 200px;
      height: 200px;
      max-width: 200px;
      max-height: 200px;
      right: 0;
      bottom: 0;
    }
    .hero-blob-icon .flame-hand-icon{ width: 120px; top: 47.25%; }
  }

  /* ============================================================
     SMALL UI — logo mark, top line, tagline, scroll cue (the old
     in-hero .nav lived here too — replaced site-wide by SITE NAV
     below, own follow-up).
  ============================================================ */
  .top-line{
    position: absolute;
    left: 50%;
    top: 5.5%;
    width: 47.2%;
    border-top: 1px solid var(--ink);
    z-index: 20;
  }
  .tagline{
    position: absolute;
    left: 66%;
    top: 8.2%;
    width: max-content;
    max-width: 300px;
    font-size: 17px;
    line-height: 25px;
    font-weight: 400;
    color: var(--ink);
    z-index: 20;
    margin: 0;
  }
  /* own follow-up (longer tagline copy): EST 2017 reads as a small
     tracked-out stamp rather than a third line of body copy — keeps
     the much longer paragraph above from feeling like it just trails
     off into a 4th sentence. */
  .tagline__est{
    display: inline-block;
    margin-top: 6px;
    font-size: 11px;
    line-height: 1;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    opacity: 0.65;
  }
  .scroll-cue{
    position: absolute;
    left: 4.2%;
    bottom: 3%;
    font-size: 17px;
    line-height: 24px;
    font-weight: 400;
    color: var(--ink);
    z-index: 20;
    margin: 0;
    white-space: nowrap; /* own fix, explicit request ("move the arrow inline... fits on one line") — the TEXT itself never wraps mid-sentence; if it and the arrow together don't fit, the arrow (a separate inline-block sibling, not part of this nowrap run) drops to its own line instead, see .scroll-cue__arrow below */
  }
  .scroll-cue .sr-only{
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip: rect(0,0,0,0);
    white-space: nowrap;
    border: 0;
  }
  /* own rebuild, explicit request ("type on instead of appearing
     static... reserve the line's final width so nothing shifts"):
     .scroll-cue__text is empty at rest — startScrollCueTyping() below
     (in the script) fills it in character by character. minWidth is
     set once, in JS, to the FULL text's own natural rendered width
     (measured off-screen before typing starts) so the line's own box
     never grows as characters are added — nothing after it (the
     cursor, the arrow once it appears) has anything to shift because
     of. */
  .scroll-cue__text{
    display: inline-block;
    vertical-align: baseline;
  }
  /* blinking caret while typing — own build, same is-typing-gated
     pattern that fixed the stuck-caret bug in the situations section's
     own typewriter (see .situations-headline.is-typewriter.is-typing
     .situations-headline__cursor): visible ONLY while .scroll-cue
     carries .is-typing, removed the instant typing completes, so
     there's no class of "leftover static caret" state to land in. */
  .scroll-cue__cursor{
    display: none;
    margin-left: 0.03em;
  }
  .scroll-cue.is-typing .scroll-cue__cursor{ display: inline-block; }
  @media (prefers-reduced-motion: no-preference){
    .scroll-cue__cursor{ animation: scrollCueCursorBlink 1s step-end infinite; }
  }
  @keyframes scrollCueCursorBlink{ 50%{ opacity: 0; } }
  /* own fix, explicit request ("move the arrow... immediately after
     'started', vertically aligned to the text's optical center, not
     its baseline"): inline-block + a small negative top nudge is what
     actually centers a small icon against a line of text's optical
     middle — vertical-align:middle alone aligns to the x-height
     midline of the FONT's own metrics, which reads slightly high
     against a mostly-lowercase sentence; the extra -0.05em nudges it
     down to the sentence's true visual center. Own choice, checked
     live against the rendered line, not calculated from font metrics
     that aren't published for this typeface. Fades/slides in via
     .is-visible (see startScrollCueTyping()) once typing completes —
     the bob keyframe only starts once that transition's own
     transitionend fires (see the script), so the two motions never
     visibly overlap mid-transition. */
  .scroll-cue__arrow{
    display: inline-block;
    width: 0.7em;
    height: 0.7em;
    margin-left: 6px;
    vertical-align: middle;
    position: relative;
    top: -0.05em;
    color: var(--color-orange);
    opacity: 0;
    transform: translateY(4px);
    transition: opacity .35s ease-out, transform .35s ease-out;
  }
  .scroll-cue__arrow.is-visible{
    opacity: 1;
    transform: translateY(0);
  }
  .scroll-cue__arrow svg{
    display: block;
    width: 100%;
    height: 100%;
  }
  /* slow, gentle bob — own choice, arrow only (the text stays put) —
     matches this file's own "idle motion reads as alive, not busy"
     convention elsewhere (e.g. the floating client blobs' own
     breathe). Only starts once .is-bobbing is added (after the fade/
     slide-in transition above finishes — see the script), so the two
     motions read as two distinct beats instead of a bob layered on
     top of an still-sliding element. Off under reduced motion via
     this file's own .reduce-motion html class, same convention as
     every other motion rule in this file, rather than a separate
     @media query. */
  @keyframes scrollCueArrowBob{
    0%, 100%{ transform: translateY(0); }
    50%{ transform: translateY(4px); }
  }
  .scroll-cue__arrow.is-bobbing{ animation: scrollCueArrowBob 2.2s ease-in-out infinite; }
  .reduce-motion .scroll-cue__arrow.is-bobbing{ animation: none; }

  /* own fix, per real-device report: the tagline's own left:66% put it
     hard against the right edge on a narrow phone (its 230px max-width
     had nowhere to go but off-screen), and .top-line's rule sat at the
     same top offset as "Menu" in the fixed nav above it, visually
     crossing through the nav text. Moved below the fixed nav entirely
     (--nav-height + a little breathing room), left-aligned within the
     standard 20px gutter, and .top-line hidden here rather than
     repositioned — its only job was to visually connect the old
     same-row nav to the tagline, a relationship that no longer exists
     once the tagline drops below a full-width nav bar. .scroll-cue
     gets a bit more bottom clearance too, as a safety margin against
     mobile browser chrome resizing the visual viewport after load. */
  @media (max-width: 767px){
    .top-line{ display: none; }
    .tagline{
      left: 20px;
      right: 20px;
      /* own fix: was calc(64px + 20px) to clear the fixed nav — now
         redundant and double-counted, since .hero itself (see its own
         max-width:767px override above) already starts below the nav
         via margin-top:64px, and this percent/px position is relative
         to hero's own (now correctly offset) top edge, not the
         viewport. Plain 20px is the intended clearance from the
         hero's own top. */
      top: 20px;
      width: auto;
      max-width: none;
      font-size: 13px;
      line-height: 18px;
    }
    .tagline__est{
      margin-top: 4px;
      font-size: 10px;
    }
    .scroll-cue{ bottom: 6%; }
  }

  /* ============================================================
     HEADLINE — three independently-positioned lines, matching
     the report's exact per-line offsets. All three (ECHO, BOOMER,
     DIGITAL) warp identically now. Each line's real text stays in
     the DOM — present for SEO/screen readers — but is visually
     hidden (opacity:0, still in the a11y tree) — with a single
     shared WebGL <canvas> laid on top rendering the warped version
     of all three. See the WARP RENDERER section below for the
     canvas side of this.
  ============================================================ */
  .headline-wrap{
    position: absolute;
    inset: 0;
    z-index: 3;
    pointer-events: none;
  }
  .headline-inner{
    position: absolute;
    inset: 0;
    transform-origin: left center;
    transform: scale(var(--fit, 1));
  }
  h1.headline{
    margin: 0;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    color: var(--ink);
  }
  /* Font-size is capped by BOTH vw and vh (not just vw) so the
     three-line stack always fits within one screen, on any
     aspect ratio. */
  .line-slot{
    position: absolute;
    white-space: nowrap;
    font-size: clamp(120px, min(21.7vw, 30vh), 420px);
  }
  /* own fix, per explicit request ("give ECHO/BOOMER/DIGITAL more
     spacing"): top offsets were an even 23-percentage-point grid
     (10/33/56), which measured out to only ~19px of gap between each
     line's box on a 1728x931 viewport — the three words read as
     visually cramped even though they don't collide (BOOMER's own
     indent keeps its glyphs clear of ECHO/DIGITAL horizontally).
     Widened the gaps to 27 points (10/37/64), which measures out to
     ~52px between boxes on that same viewport — comfortably more
     breathing room — while DIGITAL's bottom still clears .scroll-cue
     by ~35px, so nothing new collides. Mobile gets its own explicit
     override restoring the original 10/33/56 rhythm below (already
     tuned for the much shorter svh hero and verified working), since
     this base rule is otherwise unscoped and would've changed both. */
  .line-slot--1{ left: 3.8%; top: 10%; line-height: 0.69; }
  .line-slot--2{ left: 34.0%; top: 37%; line-height: 0.69; }
  .line-slot--3{ left: 3.8%; top: 64%; line-height: 0.96; }

  /* own follow-up, per explicit request: the desktop clamp's 120px
     floor was still the binding constraint at most phone widths (its
     21.7vw branch only overtakes it above ~553px), so on mobile the
     font size was effectively FIXED at 120px regardless of viewport,
     relying entirely on the JS --fit rescue (see fitHeadline()) to
     avoid overflow — real fluid, vw-driven sizing per spec, not a
     fixed size a script has to shrink after the fact. Floor dropped
     low enough (56px) that it only ever binds on genuinely tiny/
     landscape viewports, so 22vw does the real work: BOOMER (longest
     word, ~6 characters at this weight) lands close to the ~350px
     mobile content width (375px viewport minus 20px gutters) across
     the phone width range this targets, with ECHO/DIGITAL sized
     identically since all three still share this one class, and
     BOOMER's own indent (line-slot--2's left:34% above) untouched. */
  @media (max-width: 767px){
    .line-slot{ font-size: clamp(56px, 22vw, 200px); }
    /* preserves the original 10/33/56 top rhythm here (own fix above
       widened these for desktop only) — this mobile layout was already
       tuned and verified against the much shorter svh hero. */
    .line-slot--2{ top: 33%; }
    .line-slot--3{ top: 56%; }
  }

  .line-text{
    position: relative;
    display: block;
    /* own fix, explicit request ("drop the cursor distortion... does
       the headline still need a WebGL canvas at all, or can it be
       plain DOM text? If plain text works, switch to it"): it does —
       this used to be opacity:0, invisible, with a WebGL canvas
       (see git history — .headline-canvas, three HeadlineFX instances,
       removed) painting a warped copy on top of it instead. That
       warp is exactly what let the base layer drift out of sync with
       .hero-reveal's own plain, undistorted duplicate, which is what
       caused the doubled-letter-edges bug fixed last batch by fading
       the canvas — this removes the mismatch at the source instead:
       with no distortion of any kind, the base layer IS just this
       real text, identical to the reveal layer by construction, so
       nothing can ever drift out of alignment again. Real text,
       always visible — present for SEO/screen readers same as
       before, just genuinely painted now instead of a transparent
       placeholder for a canvas. */
    opacity: 1;
  }

  /* ============================================================
     SMOKE TRAIL — a soft, drifting smoke-like wisp following recent
     cursor motion: a curl-noise-advected, decaying accumulation
     buffer stamped by the cursor each frame, rather than a particle
     system. See the SMOKE TRAIL section in the script for the actual
     shader/mechanism and SMOKE_CONFIG for every tunable constant.
     Own compositing choice for this page: the canvas uses
     CSS mix-blend-mode, switched per section via the same
     data-theme detection data-trail="off" already used elsewhere —
     "multiply" with a neon-to-orange flame gradient over light/cream
     sections (own follow-up — used to be a flat ink color, reading
     as dark smoke; now reads as flame instead, explicit request),
     "screen" with a teal-to-white core over dark sections (reads as
     a glow) — multiply stays the light-section blend mode even with
     the new bright colors because true additive/screen blending
     washes out to white against a light background — see
     .mode-multiply/.mode-screen below and trailIsLight in the
     script.
     PAGE-WIDE, not hero-scoped: the canvas is a fixed,
     full-viewport layer that's a direct child of <body> (not
     nested in any section), so it isn't clipped by a section's own
     overflow:hidden and keeps covering the screen as the page
     scrolls. z-index 50 sits above every section's own content
     (highest other z-index anywhere on the page is 25, the hero's
     own FLAME MASK reveal layer) and BELOW the flame cursor
     (z-index 60), matching the hero's original
     "above content, below the flame hand" layering. See
     data-trail="off" below for the per-area opt-out.
  ============================================================ */
  .pixel-trail-canvas{
    position: fixed;
    inset: 0;
    z-index: var(--z-trail-canvas);
    display: block;
    pointer-events: none;
  }
  .pixel-trail-canvas.mode-multiply{ mix-blend-mode: multiply; }
  .pixel-trail-canvas.mode-screen{ mix-blend-mode: screen; }
  .reduce-motion .pixel-trail-canvas{ display: none; }

  /* ============================================================
     FLAME CURSOR — replaces the system cursor PAGE-WIDE (own
     follow-up, widened from the earlier hero/statement/works/showcase-
     only version per explicit request: "the actual cursor", matching
     the pixel trail's own page-wide scope above) on fine-pointer
     devices (mouse/trackpad only; nothing shows on touch). Two-layer
     structure:
       .flame-cursor        outer wrapper — position ONLY, moved
                             with a raw JS `transform: translate()`
                             on every pointermove with NO CSS
                             transition, so it tracks the mouse
                             with zero added lag.
       .flame-cursor-inner  inner wrapper — rotate()/scale() only,
                             CSS-transitioned so the direction-tilt
                             settles back smoothly. transform-origin
                             is set from JS to the match-flame tip
                             (the hotspot) so both rotation and
                             scale pivot around that exact point.
     Color switches per-section (own follow-up — used to be fixed):
     white over dark sections, ink over light/cream sections, via the
     same closest('[data-theme="light"]') check the pixel trail and
     footer doodles already use — set in the script's handleMove, not
     here.
     Native cursor: hidden page-wide — the flame mark is the cursor,
     visible over every element including links, buttons and cards
     (explicit request, own follow-up: an earlier version faded the
     flame out and restored the native pointer/text cursor over
     interactive elements and a set of opt-out zones — removed per
     direct feedback that this suppression was unwanted). Two
     deliberate, reconfirmed exceptions remain, both scoped narrowly
     rather than through that removed generic mechanism: #hero (see
     .flame-cursor.is-hero-suppressed below) and plain text fields
     (input/textarea) here, where the native text caret is a functional
     need for knowing where typed text lands, not a "clickability"
     affordance, so it stays.
  ============================================================ */
  html.has-flame-cursor body,
  html.has-flame-cursor body *{
    cursor: none !important;
  }
  html.has-flame-cursor input,
  html.has-flame-cursor textarea{
    cursor: text !important;
  }
  .flame-cursor{
    position: fixed;
    left: 0;
    top: 0;
    z-index: var(--z-flame-cursor);
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.15s ease, color 0.15s ease;
    will-change: transform;
  }
  .flame-cursor.is-active{ opacity: 1; }
  /* Hero is the one deliberate exception (explicit, reconfirmed): the
     hero's own large centerpiece icon already follows the cursor
     there (see .hero-blob-icon .flame-hand-icon comment further
     down), so the small roaming flame mark stays at opacity 0 inside
     #hero specifically — scoped to this one section only, not the
     generic "every link/button" suppression that was removed above.
     Team-slider is the second, later-confirmed exception (explicit,
     screenshot-driven follow-up): its own orange direction-cursor
     circle already tells you which way a swipe goes, and the small
     roaming flame mark sitting right on top of/next to that circle
     read as visual clutter — same "this section already has its own
     cursor treatment" reasoning as the hero, so it gets the identical
     scoped opacity-0 treatment via is-teamslider-suppressed below. */
  .flame-cursor.is-hero-suppressed,
  .flame-cursor.is-teamslider-suppressed{ opacity: 0 !important; }
  .flame-cursor-inner{
    width: 40px;
    transition: transform 0.12s ease-out;
  }
  .flame-cursor-inner svg{
    display: block;
    width: 40px;
    height: auto;
  }

  /* ============================================================
     STATEMENT SECTION — below the hero. Fluid type-size scale via a
     measured clamp() formula: fixed at/below a small reference
     viewport, fixed (larger) at/above a large one, linear vw between.
     The reveal itself is our own letter-by-letter illumination
     (ILLUMINATE_CONFIG in the script below); the background is a
     faint wavy-line texture (WAVE_BG_CONFIG), replacing an earlier
     technical-grid background per explicit follow-up ("let's get
     away from the grid background... replace it with something
     softer") — a continuous soft field of hairline horizontal waves
     behind the type. Own build: a small SVG tile (two mirrored wave
     rows, each a single symmetric cubic bezier chosen so its start/end
     tangents match — the same trick that makes it repeat seamlessly
     in both directions) set as a CSS background-image, not their
     source. See WAVE_BG_CONFIG's own comment further down.
  ============================================================ */
  .statement{
    position: relative;
    overflow: hidden;
    /* Own fix: was a flat var(--color-near-black) with no crossfade
       hook — now the same --X-bg indirection every other seam-crossed
       section already uses, so the SEAM_CONFIG script (below, near
       the end of the file) can blend this into works' new cream
       right at the boundary instead of a hard cut. Fallback stays
       near-black, this section's own resting color, for the
       prefers-reduced-motion path where that script never runs. */
    background: var(--statement-bg, var(--color-near-black));
    padding: 6vh 5vw; /* vertical padding on vh, not vw — vw padding at a wide/short viewport was adding hundreds of px of extra height, which the JS fit-check (measuring the text alone) didn't know about, and the section grew taller than one viewport as a result */
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  /* Wave background — one small SVG tile (built once in JS, see
     WAVE_BG_CONFIG/buildWaveSvg below) repeated by the browser via
     plain CSS background-repeat, nothing to redraw on resize. Same
     radial-gradient mask the old grid background used, so it still
     reads strongest behind the text and fades toward the section's
     edges. */
  .wave-bg{
    position: absolute;
    inset: 0;
    pointer-events: none;
    background-image: var(--wave-bg-image);
    background-repeat: repeat;
    background-size: var(--wave-tile-w) var(--wave-tile-h);
    -webkit-mask-image: radial-gradient(ellipse 68% 62% at 50% 50%, #000 0%, transparent 100%);
    mask-image: radial-gradient(ellipse 68% 62% at 50% 50%, #000 0%, transparent 100%);
  }
  .statement-inner{
    position: relative;
    width: 90vw;
    max-width: 2200px;
    margin: 0 auto;
  }
  /* Fluid type size — fixed at/below a small reference viewport,
     fixed (larger) at/above a large one, linear vw between — set as
     --statement-fs by applyStatementFontSize() in the script, plus a
     fit check that shrinks it further if the rendered block would
     ever exceed the viewport height. Archivo Expanded/Black is this
     build's pick for an extra-wide, heavy display font here — our
     headline font (DM Sans) doesn't come in a wide/black cut, so it
     wasn't a fit here the way it is for the hero's own headline. */
  .statement-text{
    position: relative;
    z-index: 3; /* above .statement-cards (z-index 2), in the SAME stacking context as it (.statement-inner has no z-index of its own, so it doesn't isolate) — mix-blend-mode below only reaches across to .statement-cards because this element carries both the z-index and the blend mode together, exactly like the old .statement-text--fill layer did */
    margin: 0;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 800;
    text-align: center;
    line-height: 0.9;
    letter-spacing: 0;
    font-size: var(--statement-fs, 80px); /* own follow-up: sentence case, own request — no text-transform: capitalize (caps every word) or ::first-letter (the letter-by-letter reveal splits the text into per-letter spans, so ::first-letter never matches); ILLUMINATE_CONFIG.textPlain below is already written sentence-case, so removing the old uppercase transform is the whole fix */
    mix-blend-mode: difference; /* restored from the pre-illumination .statement-text--fill layer: against our near-black section background it's close to a no-op, but wherever a lit letter crosses one of the floating cards below it reads as a striking color-inverted flash. A first attempt put this on a descendant span instead (.statement-text__lit) — since THIS element already establishes its own stacking context (position+z-index above), a blend mode set one level down never escaped it to reach .statement-cards at all. A second attempt replaced the blend with per-letter opacity, which worked but changed the look (no inversion, and every letter that merely touched a card edge got dimmed everywhere, even over the plain background) — reverted; the inverted look is the intended one. */
  }
  .reduce-motion .statement-text{ mix-blend-mode: normal; } /* reduced motion: flat final colors, no card-crossing invert flash — matches the old .reduce-motion .statement-text--fill{ display:none } simplification */
  /* ---- letter-by-letter illumination ----
     Built by buildStatementText() in the script: every word is its
     own inline-block span (so a line only ever breaks between words,
     never mid-word), and — in the default "letter"
     ILLUMINATE_CONFIG.mode — every letter inside it is its own span
     too, colored/lifted directly by updateIllumination() on each
     scroll tick. No CSS transition here on purpose: the per-frame JS
     already interpolates color/position every frame, and a competing
     CSS transition would just fight it and add lag. The one
     genuinely highlighted run of words ("Passionate oddballs") is
     still wrapped in a single .hl span exactly as before, since
     DOODLES_CONFIG's sparkler doodle anchors to it by that selector.
     .sr-only carries the plain, real sentence for screen readers; the
     decorative word/letter spans sit in a sibling marked
     aria-hidden — it contributes no pixels of its own, so blending
     the whole .statement-text paragraph (above) is visually
     identical to blending just the visible span. ---- */
  .statement-text .word{
    display: inline-block;
    white-space: nowrap;
  }
  .statement-text .ltr{
    display: inline-block;
    color: var(--color-near-black);
  }
  .statement-text .sr-only{
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip: rect(0,0,0,0);
    white-space: nowrap;
    border: 0;
  }

  /* ---- floating client blobs ----
     Own rework, per explicit request ("less like the [tidy two-
     column] reference layout"): organic blob shapes instead of
     rounded rectangles, scattered at varied sizes around the
     headline instead of alternating left/right down the section.
     ALL SIX STILL MOVE TOGETHER AS ONE GROUP at a single scroll
     parallax speed via one transform on .statement-cards itself
     (unchanged — see updateCardGroupDrift() in the script); each
     blob's own idle morph/breathe (CSS) and mouse-drift (JS, on its
     own wrapper) layer on top of that shared group transform rather
     than replacing it. */
  .statement-cards{
    position: absolute;
    inset: 0;
    z-index: 2; /* above the base text, below the fill/wipe text layer — letters crossing a blob is the point */
    pointer-events: none;
    will-change: transform;
  }
  .floating-card{
    position: absolute;
  }
  /* the wander/mouse-avoid transform target — JS sets translate()
     here every frame (see the script's own blob wander loop): a
     continuous random-waypoint drift around the section at rest,
     pushed away from the cursor and from other blobs on approach.
     Kept separate from .floating-card__blob's own morph/breathe
     animation below it, so no element ever has two animated things
     fighting over the same transform property. Also just a plain
     flex column so the label can sit right after the blob in normal
     flow (see .floating-card__label) rather than needing its own
     position math to stay outside the blob's curved edge. */
  .floating-card__wrap{
    display: flex;
    flex-direction: column;
    align-items: center;
    will-change: transform;
  }
  .floating-card__blob{
    position: relative;
    overflow: hidden;
    width: var(--blob-size);
    height: var(--blob-size);
    background: var(--color-near-black); /* shows briefly while the image loads */
    /* static fallback shape — own fix: previously this element had NO
       border-radius outside the animation keyframes, so
       prefers-reduced-motion's `animation: none` (below) fell back to
       the CSS initial border-radius (0 — a square), not a blob. Same
       value as blobMorphA's own 0% keyframe, so a freshly-loaded (or
       reduced-motion) blob and a mid-animation one read as the same
       family of shape. */
    border-radius: 58% 42% 51% 49% / 46% 55% 45% 54%;
    will-change: border-radius, transform;
    animation: var(--blob-anim, blobMorphA) var(--blob-duration, 11s) var(--blob-delay, 0s) ease-in-out infinite,
               blobBreathe var(--breathe-duration, 7s) var(--breathe-delay, 0s) ease-in-out infinite;
  }
  .floating-card__img{
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
  /* outside the blob now (not overlapping the photo), so the old
     bottom-scrim gradient that used to keep it legible over the
     image is gone too — nothing behind this label but the section's
     own near-black background. */
  .floating-card__label{
    margin-top: 10px;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 12px;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-cream);
    white-space: nowrap;
  }

  /* ---- idle morph/breathe ----
     Three shape-cycle variants, assigned across the six blobs below
     so adjacent blobs rarely share one — combined with each blob's
     own duration/delay (also set per-blob below), that's enough for
     them to visibly desync without needing six entirely separate
     keyframe rules. Own follow-up ("we just need the blobs to sort
     of breathe subtly"): the scale breathe used to live inside these
     same border-radius keyframes, which read as a wobble rather than
     a clean breath since both properties changed on the same lumpy
     schedule. Split into two independent animations on the same
     element instead (a plain comma-separated animation list — no
     property collision, since one only ever touches border-radius
     and the other only ever touches transform): blobMorphA/B/C below
     stay shape-only, and a single shared blobBreathe keyframe (own
     rule, further down) handles a smooth, symmetric scale pulse on
     its own independent duration/delay per blob, so shape-drift and
     breathing visibly desync from each other too. Negative delays
     start each blob already mid-cycle on load, instead of all six
     visibly starting from the same frame together. */
  @keyframes blobMorphA{
    0%{ border-radius: 58% 42% 51% 49% / 46% 55% 45% 54%; }
    33%{ border-radius: 42% 58% 47% 53% / 53% 46% 54% 47%; }
    66%{ border-radius: 51% 49% 63% 37% / 40% 58% 42% 60%; }
    100%{ border-radius: 58% 42% 51% 49% / 46% 55% 45% 54%; }
  }
  @keyframes blobMorphB{
    0%{ border-radius: 47% 53% 40% 60% / 58% 45% 55% 42%; }
    33%{ border-radius: 60% 40% 55% 45% / 42% 60% 38% 62%; }
    66%{ border-radius: 44% 56% 52% 48% / 55% 42% 58% 45%; }
    100%{ border-radius: 47% 53% 40% 60% / 58% 45% 55% 42%; }
  }
  @keyframes blobMorphC{
    0%{ border-radius: 55% 45% 58% 42% / 48% 52% 44% 56%; }
    50%{ border-radius: 40% 60% 45% 55% / 60% 40% 58% 42%; }
    100%{ border-radius: 55% 45% 58% 42% / 48% 52% 44% 56%; }
  }
  /* pure scale pulse, own subtle amplitude (1 -> 1.03) — deliberately
     smaller than the old combined wobble's peak (1.04) since this now
     reads as the ONLY scale motion, not one competing with a shape
     change at the same instant. */
  @keyframes blobBreathe{
    0%{ transform: scale(1); }
    50%{ transform: scale(1.03); }
    100%{ transform: scale(1); }
  }

  /* ---- per-blob placement/size/timing ----
     Real client photography, one per blob — see
     /hero-v2/assets/clients. Scattered (own choice, per explicit
     "nothing lines up in a grid" request) rather than the old tidy
     left/right alternation: irregular left/right insets, sizes
     between 180-340px (Hyatt and Bkf the two "noticeably bigger"
     ones per spec), slight overlaps with the headline's own edges
     left in place deliberately. */
  #cardPoppi{ top: 3%; left: 1%; }
  #cardPoppi .floating-card__blob{ --blob-size: 220px; --blob-anim: blobMorphA; --blob-duration: 10s; --blob-delay: -1s; --breathe-duration: 6.5s; --breathe-delay: -1s; }

  #cardHyatt{ top: 1%; right: 7%; }
  #cardHyatt .floating-card__blob{ --blob-size: 340px; --blob-anim: blobMorphB; --blob-duration: 13s; --blob-delay: -6s; --breathe-duration: 8s; --breathe-delay: -4s; }

  #cardChilis{ top: 33%; left: 15%; }
  #cardChilis .floating-card__blob{ --blob-size: 190px; --blob-anim: blobMorphC; --blob-duration: 9s; --blob-delay: -3s; --breathe-duration: 5.5s; --breathe-delay: -2.5s; }

  #cardWinterPark{ top: 45%; right: 1%; }
  #cardWinterPark .floating-card__blob{ --blob-size: 260px; --blob-anim: blobMorphA; --blob-duration: 12s; --blob-delay: -8s; --breathe-duration: 7.5s; --breathe-delay: -6s; }

  #cardWildeChips{ top: 71%; left: 23%; }
  #cardWildeChips .floating-card__blob{ --blob-size: 180px; --blob-anim: blobMorphC; --blob-duration: 11s; --blob-delay: -5s; --breathe-duration: 6s; --breathe-delay: -3.5s; }

  #cardBkf{ top: 79%; right: 11%; }
  #cardBkf .floating-card__blob{ --blob-size: 300px; --blob-anim: blobMorphB; --blob-duration: 14s; --blob-delay: -10s; --breathe-duration: 8.5s; --breathe-delay: -7s; }

  /* pause every blob's own idle shape-morph while the section itself
     is offscreen — toggled by the script's own IntersectionObserver,
     not a media query. The JS wander loop checks the same flag
     itself before scheduling its next frame. */
  .statement.is-offscreen .floating-card__blob{
    animation-play-state: paused;
  }

  @media (prefers-reduced-motion: reduce){
    .floating-card__blob{ animation: none; } /* static blob shape (the base rule's own border-radius, now that it has one — see that rule's comment), no morph, no breathe; JS wander loop is skipped entirely under this query too */
  }

  /* below a reference breakpoint (1024px, confirmed against a
     different reference — carried forward here since this section's
     own reference couldn't be confirmed either way on mobile):
     smaller text, fewer blobs, no scroll-driven motion. The JS also
     checks this same width once at load (see script). Idle
     morph/breathe keeps running here (own explicit request: "idle
     motion only" on mobile) — only the mouse-drift JS turns off,
     gated on canHover in the script rather than this breakpoint,
     since a touch device simply has no persistent cursor position to
     react to. !important because each blob's own --blob-size is set
     by a more specific per-#id selector above; this is the one clean
     way to still win at this breakpoint without repeating all six
     selectors again just to shrink them. */
  @media (max-width: 1024px){
    .statement{ padding: 8vh 6vw; }
    .floating-card--hide-mobile{ display: none; }
    .statement-cards{ transform: none !important; }
    .floating-card__blob{ --blob-size: 34vw !important; }
  }

  /* own fix, per real-device report: the three mobile-visible blobs
     (Poppi/Hyatt/WinterPark) keep their desktop scattered top/left/
     right percentages at this breakpoint too — on a much narrower,
     vertically-centered headline (.statement uses align-items:center,
     so the text block sits roughly in the middle 40-70% of the
     section), those percentages land two of them (Poppi top:3%,
     Hyatt top:1%) right at the very top overlapping the section's
     own top edge, and the third (WinterPark top:45%) squarely on top
     of "scale" mid-headline. Repositioned into the empty zones above
     (top corners) and below (bottom, centered) the vertically-
     centered headline instead — percentages here are relative to the
     same .statement box, so they clear a headline block of ~normal
     height regardless of exactly how tall the text ends up being.
     Size nudged to a fixed 130px (was 34vw ≈ 132px at this width —
     effectively unchanged, just no longer viewport-relative so it
     doesn't grow past spec on slightly wider phones). */
  /* own follow-up, mobile-only ("breathing is barely visible, blobs
     never leave their corners"): a much bigger breathe amplitude and
     a wider shape morph than desktop's own (subtler) versions —
     separate keyframes so desktop is untouched — plus a slow CSS
     translate drift on .floating-card__wrap. Desktop's JS wander
     already writes inline transforms to that same element (mouse
     avoidance), but that JS is gated off on mobile (canHover false),
     so a CSS animation on the same property there has nothing to
     fight with.

     own second follow-up ("still reads as static on a real phone —
     diagnose first, don't guess"): confirmed via computed-style
     sampling AND actual screenshot pixel-diffing (both engines) that
     the 0.9–1.1 scale / ~30–70% radius swing this shipped with WAS
     genuinely animating, at a real, measurable pixel delta every
     frame — not a stuck/paused/overridden animation. It just didn't
     read as "breathing" at these blob sizes. Pushed materially
     further (0.8–1.2 scale, ~18–82% radius swing — both close to
     double the old peak-to-peak amplitude) rather than nudging again;
     durations/delays (6–11s per blob, unchanged) keep it slow and
     eased rather than a pulse. The four corner blobs' own insets
     (below) grew from 24/20px to 32px to hold the same real-device
     ≥16px-from-edge floor at this bigger max scale — see that rule's
     own comment. */
  @keyframes blobBreatheMobile{
    0%{ transform: scale(0.8); }
    50%{ transform: scale(1.2); }
    100%{ transform: scale(0.8); }
  }
  @keyframes blobMorphAMobile{
    0%{ border-radius: 78% 22% 62% 38% / 32% 68% 24% 76%; }
    33%{ border-radius: 18% 82% 34% 66% / 74% 26% 66% 34%; }
    66%{ border-radius: 58% 42% 80% 20% / 20% 76% 22% 78%; }
    100%{ border-radius: 78% 22% 62% 38% / 32% 68% 24% 76%; }
  }
  @keyframes blobMorphBMobile{
    0%{ border-radius: 24% 76% 18% 82% / 80% 22% 76% 20%; }
    33%{ border-radius: 80% 20% 74% 26% / 24% 78% 18% 82%; }
    66%{ border-radius: 32% 68% 68% 32% / 76% 20% 80% 22%; }
    100%{ border-radius: 24% 76% 18% 82% / 80% 22% 76% 20%; }
  }
  /* own follow-up ("blobs still don't move off their corners on a
     real iPhone — measure, don't eyeball"): a WebKit iPhone 13 rect
     log confirmed the previous single-direction 0%->100% keyframes
     (max ~16px displacement) WERE actually running with nothing
     overriding them — the amplitude itself was just too small to
     read as motion. These are true 4-point loops (not just there-
     and-back) and each biased toward the section's OWN center rather
     than symmetric, so growing the amplitude to the requested 40-60px
     never pushes a blob toward the edge it already starts closest to
     — see the corner placement rules below for the measured
     clearances this was sized against. Top row gets the bigger
     vertical swing (headline starts well below it); bottom row's
     vertical swing is held a bit tighter (less clearance up to the
     headline) while still clearing 40px. */
  @keyframes driftPoppi{
    0%{ transform: translate(0,0); }
    33%{ transform: translate(45px,45px); }
    66%{ transform: translate(15px,58px); }
    100%{ transform: translate(0,0); }
  }
  @keyframes driftHyatt{
    0%{ transform: translate(0,0); }
    33%{ transform: translate(-45px,48px); }
    66%{ transform: translate(-15px,60px); }
    100%{ transform: translate(0,0); }
  }
  @keyframes driftBkf{
    0%{ transform: translate(0,0); }
    33%{ transform: translate(45px,-34px); }
    66%{ transform: translate(15px,-42px); }
    100%{ transform: translate(0,0); }
  }
  @keyframes driftWinterPark{
    0%{ transform: translate(0,0); }
    33%{ transform: translate(-45px,-36px); }
    66%{ transform: translate(-15px,-44px); }
    100%{ transform: translate(0,0); }
  }

  @media (max-width: 767px){
    /* own follow-up, matching the supplied mobile-design mockup
       (01-hero-oddballs.png) exactly: 4 blobs (2 above, 2 below the
       headline), not 3 — Poppi/Hyatt top corners, Bar Keeper's
       Friend/Winter Park bottom corners, each with its label. */
    /* own follow-up ("pull the blobs in from the edges so none are
       clipped, >=16px inside the section"): a live WebKit iPhone 13
       measurement of the previous %-based insets found real clearances
       of ~1.6px (Bkf) to ~6.6px (Hyatt/WinterPark) — well under the
       16px floor once you account for the blob's own breathing scale
       (up to 1.1x) overhanging its box by several more px on top of
       that. Fixed px insets (not %) hold that floor at any phone
       width in this breakpoint, and the drift keyframes above are
       biased inward from here so drifting only ever adds clearance,
       never removes it.

       own follow-up (breathe amplitude raised to 0.8–1.2 scale, see
       blobBreatheMobile's own comment): re-solved for the same ≥16px
       real-device floor at the new, bigger max scale — 24/20px would
       now put the edge at 12px under (0.6 × 120px half-width vs a
       24px inset leaves only 24-12=12px). 32px on every side holds
       ≥20px clear at 1.2x, i.e. still comfortably past the floor. */
    /* own fix, explicit feedback ("too much space around the text and
       the blobs"): the base rule's min-height:100vh floor (further
       inflated by layoutExtraOddballsBlobs() below whenever it needs
       genuine extra room) was forcing this section to a full viewport
       or taller on every phone regardless of how little content it
       actually holds — the corner blobs stay pinned to the section's
       own top/bottom edges (32px insets, above) while the headline
       stays vertically centered, so a min-height much taller than the
       content's real footprint just opens dead air between them. 70vh
       is a tighter floor sized to comfortably clear the corner blobs
       (they need ~32px + ~145px + label ≈ 210px from each edge) with
       the centered headline still reading as roughly mid-section, not
       a literal content-fit value — layoutExtraOddballsBlobs()'s own
       "grow only if genuinely needed" logic (see its own comment)
       still takes it from here for Chili's/Wilde's own clearance, now
       against this smaller base instead of 100vh (see the matching
       70vh literal in that function — kept as two copies since one
       lives in CSS and the other in a JS-built calc() string, with
       nothing that could interpolate a shared value between them; a
       comment on each is the best that's available here). */
    .statement{ min-height: 70vh; }
    #cardPoppi{ top: 32px; left: 32px; right: auto; bottom: auto; }
    #cardHyatt{ top: 32px; right: 32px; left: auto; bottom: auto; }
    #cardBkf{ top: auto; bottom: 32px; left: 32px; right: auto; }
    #cardWinterPark{ top: auto; bottom: 32px; right: 32px; left: auto; }
    .floating-card__blob{ --blob-size: 120px !important; } /* within the 110-150px spec range; 120px keeps 4 blobs (vs. the earlier 3-blob 130px) comfortably clear of the headline at 375px */
    .floating-card__label{
      max-width: 120px;
      white-space: normal;
      text-align: center;
      line-height: 1.3;
    }

    /* own follow-up ("turn it way up"): scale 0.9-1.1, stronger
       border-radius swing, 6-9s loops desynced per blob. */
    #cardPoppi .floating-card__blob{ --blob-duration: 6s; --blob-delay: -1s; --breathe-duration: 7s; --breathe-delay: -2s; animation-name: blobMorphAMobile, blobBreatheMobile; }
    #cardHyatt .floating-card__blob{ --blob-duration: 8s; --blob-delay: -3s; --breathe-duration: 6.5s; --breathe-delay: -4s; animation-name: blobMorphBMobile, blobBreatheMobile; }
    #cardWinterPark .floating-card__blob{ --blob-duration: 9s; --blob-delay: -5s; --breathe-duration: 8s; --breathe-delay: -1.5s; animation-name: blobMorphAMobile, blobBreatheMobile; }
    #cardBkf .floating-card__blob{ --blob-duration: 7s; --blob-delay: -2.5s; --breathe-duration: 9s; --breathe-delay: -6s; animation-name: blobMorphBMobile, blobBreatheMobile; }

    /* own follow-up ("blobs never leave their corners"): a slow idle
       wander, each on its own short path, staying inside its own
       corner so it never reaches the headline. Desktop's JS mouse-
       avoidance wander is off on touch, so nothing else claims this
       element's transform here. */
    #cardPoppi .floating-card__wrap{ animation: driftPoppi 14s ease-in-out infinite; }
    #cardHyatt .floating-card__wrap{ animation: driftHyatt 18s ease-in-out infinite; animation-delay: -6s; }
    #cardWinterPark .floating-card__wrap{ animation: driftWinterPark 16s ease-in-out infinite; animation-delay: -11s; }
    #cardBkf .floating-card__wrap{ animation: driftBkf 12s ease-in-out infinite; animation-delay: -3s; }

    /* own follow-up (6-blob mobile oddballs): Chili's and Wilde Chips
       — the next two clients in the desktop blob set, previously
       hidden entirely below 1024px via .floating-card--hide-mobile —
       turned back on for this breakpoint only (ID selectors here
       out-specificity that class's display:none) and placed in the
       band between the headline and the bottom pair, one per side,
       staggered. `top` is intentionally left unset here — the section
       is flex-centered on its single headline child, so a fixed top
       offset that clears the headline+bottom-pair at one device
       height doesn't necessarily clear both at another (the bottom
       pair tracks the section's own bottom edge 1:1 as the section
       grows; the centered headline only tracks it 1:2). See
       layoutExtraOddballsBlobs() in the script, right after
       applyStatementFontSize — it measures the real rects and sets
       both `top` and (only if genuinely needed) .statement's own
       min-height at runtime instead of guessing a px value here.

       The center-55%-of-content-width no-go zone IS still a static
       CSS rule (labels are exempt — see below): each blob is
       deliberately anchored on-screen at the same 20px gutter the
       spec calls for (matching the label's home position), then
       shoved further out from center via a `left` offset on
       .floating-card__blob itself (a plain position:relative nudge,
       not `transform`, since `transform` is already owned by this
       element's own scale/morph animation) — bled far enough past its
       own wrap's box that even its own max-breathe extent stays clear
       of the center band at 360px (the narrower of the two required
       test widths, so the tighter constraint) with a real px margin
       to spare. The label is a sibling of the blob, so it never
       inherits that shift — it stays centered on the wrap's own
       on-screen anchor regardless of how far the blob itself bleeds
       toward/past the edge. Re-solved (-68px/90px → -74px/98px) when
       blobBreatheMobile's max scale grew from 1.1 to 1.2 — see that
       keyframe's own comment. */
    #cardChilis{ display: block; left: 20px; right: auto; bottom: auto; }
    #cardWildeChips{ display: block; right: 20px; left: auto; bottom: auto; }

    #cardChilis .floating-card__blob{ --blob-size: 118px !important; --blob-duration: 10s; --blob-delay: -4s; --breathe-duration: 7.5s; --breathe-delay: -3s; animation-name: blobMorphAMobile, blobBreatheMobile; left: -74px; }
    #cardWildeChips .floating-card__blob{ --blob-size: 136px !important; --blob-duration: 11s; --blob-delay: -6s; --breathe-duration: 8.5s; --breathe-delay: -5s; animation-name: blobMorphBMobile, blobBreatheMobile; left: 98px; }
  }

  /* ============================================================
     SELECTED WORKS — own build.

     GRID — 3-per-row × 2 rows, 4:5 portrait media boxes. Column/row
     gap are our own values (tight column gap, much taller row gap).
     Breakpoints: 3 → 2 → 1 column.

     CAPTION — title + one line of type/category directly under the
     media, left-aligned, plus an easy-to-edit "service · year" line
     per this task's own brief.

     HOVER — static client photography here, so the box just gets a
     light scale-up on hover as its own reaction (see .works-item__img
     below). Separately, .works-cursor below is a small dot that
     continuously tracks the raw cursor position across the whole
     page with a soft ~500ms smoothing catch-up, shown via body:hover,
     mix-blend-mode:exclusion, default label "Play" — our cherry bomb
     silhouette standing in for a plain circle (see WORKS_CONFIG + the
     cursor-follower script for the mechanism).

     SCROLL-IN — fade+rise-with-stagger, our own reasonable default
     for a "boots in on load/scroll" character.

     BACKGROUND — own fix, per explicit request: was the same near-
     black as the statement section directly above it, so the two ran
     together with no visible seam. Switched to cream — this section's
     own choice, after trying violet first: cream reads as a clean,
     confident break from statement's near-black (this file's other
     two accents, orange and teal/yellow, are reserved for interactive
     signaling and motion respectively, not resting backgrounds — see
     their own :root comments). Cream also makes this the SAME color
     as showcase directly below, so — unlike every other section
     boundary in this file — there's genuinely nothing to crossfade at
     the works/showcase seam; SEAM_CONFIG below no longer carries a
     "works" entry (removed, not just left inert) for exactly that
     reason, matching how statement/works used to have no boundary
     between them back when THEY shared a color. The statement/works
     seam (near-black -> cream, the biggest jump in the whole page)
     keeps its own crossfade via the "statement" entry there. This
     section now carries data-theme="light" (see the markup), the same
     mechanism as every other cream section in this file (showcase,
     situations, crafted, team-slider) — required here, not cosmetic:
     doodleUrl() and the pixel-trail's per-area color swap both key off
     that attribute, and without it the paper-airplane doodle would
     render in its cream-ink variant (invisible on this now-cream bg)
     and the cursor trail would keep its low-contrast-on-light default
     color while over this section. Card labels/meta sit on their own
     per-card photo + dark scrim (see .works-item__scrim below), not
     directly on this background, so they're unaffected either way;
     the heading text below reads near-black off the plain fallback,
     same static pairing every other light section in this file uses,
     no dynamic contrast-picking needed once there's no crossfade. ---- */
  .works{
    position: relative;
    background: var(--color-cream);
    color: var(--color-near-black);
    padding: 12vh 5vw 14vh;
  }
  .works-heading{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 800;
    line-height: 0.95; /* own follow-up: sentence case, own request — text-transform removed, source text below changed to match rather than relying on CSS casing */
    font-size: inherit; /* set on .works-heading-row below, which now wraps this heading */
    margin: 0; /* margin moved to .works-heading-row below */
  }
  .works-grid{
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    column-gap: 1.5vw;
    row-gap: 6vh;
  }
  @media (max-width: 991px){
    .works-grid{ grid-template-columns: repeat(2, 1fr); }
  }
  @media (max-width: 640px){
    /* own fix: swap the stacked single-column grid for a horizontal
       swipe row — each card at ~85vw so the next one peeks in at the
       edge, scroll-snap so it settles on a card rather than landing
       mid-scroll. overflow-x is scoped to this row alone (the page
       itself never gains horizontal scroll), and the row's own
       negative-margin/padding trick lets cards bleed to the true
       screen edge without widening html/body. */
    .works-grid{
      display: flex;
      grid-template-columns: none;
      overflow-x: auto;
      overflow-y: visible;
      scroll-snap-type: x mandatory;
      scroll-padding-left: 20px; /* own follow-up ("first card flush against the left edge"): balances the active card's own left gutter against the right-edge peek */
      -webkit-overflow-scrolling: touch;
      column-gap: 4vw;
      row-gap: 0;
      margin: 0 -5vw;
      padding: 0 5vw;
    }
    .works-item{
      flex: 0 0 85vw;
      scroll-snap-align: start;
    }
    /* dot indicators below the swipe row — own addition, built by the
       script (WORKS section's own IIFE) since it needs to track scroll
       position; markup/behavior there, purely visual styling here. */
    .works-dots{
      display: flex;
      justify-content: center;
      gap: 8px;
      margin-top: 4vh;
    }
    /* 44px real tap target (min-width/height on the button itself)
       around a much smaller 8px visual dot (::after) — a visibly tiny
       dot with a full-size invisible hit area, rather than shrinking
       the tap target to match the dot's own look. */
    .works-dots__dot{
      position: relative;
      width: 44px;
      height: 44px;
      min-width: 44px;
      min-height: 44px;
      padding: 0;
      margin: -18px;
      border: none;
      background: none;
      cursor: pointer;
      /* own fix ("stray outlined pills under the work cards"): this is
         a real <button> (see script), and border:none/background:none
         above don't reliably strip a browser's OWN native form-control
         chrome on every engine — WebKit in particular is known to keep
         rendering its default rounded-rect button outline unless
         appearance is explicitly turned off too, which this rule never
         did. Confirmed via computed style: appearance read back "auto"
         here before this fix. The visible dot itself was always the
         ::after pseudo-element below, never this button's own native
         look — this was never meant to show at all, on any engine. */
      appearance: none;
      -webkit-appearance: none;
    }
    .works-dots__dot::after{
      content: "";
      position: absolute;
      top: 50%; left: 50%;
      transform: translate(-50%, -50%);
      width: 8px;
      height: 8px;
      border-radius: 999px;
      background: color-mix(in srgb, var(--color-near-black) 25%, transparent);
      transition: background .2s ease, width .2s ease;
    }
    .works-dots__dot.is-active::after{
      width: 20px;
      background: var(--color-near-black);
    }
  }
  .works-item{
    display: block;
    color: inherit;
    text-decoration: none;
    /* scroll-in reveal, own timing (see block comment above) —
       toggled by .is-in, added via IntersectionObserver in script. */
    opacity: 0;
    transform: translateY(28px);
    transition: opacity 0.8s cubic-bezier(.16,1,.3,1), transform 0.8s cubic-bezier(.16,1,.3,1);
  }
  .works-item.is-in{
    opacity: 1;
    transform: translateY(0);
  }
  .works-item__media{
    position: relative;
    aspect-ratio: 4 / 5; /* CONFIRMED, see block comment above */
    width: 100%;
    border-radius: 18px; /* matches the statement section's own floating-card radius */
    overflow: hidden;
    background: color-mix(in srgb, var(--color-cream) 88%, var(--color-near-black)); /* shows briefly while the image loads — own fix: a soft warm grey off the section's own new cream bg (was flat near-black) so a not-yet-loaded card reads as part of the section rather than flashing a contrasting black box, while staying visibly distinct from the bg so the card's own shape still shows */
  }
  .works-item__img{
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: 18px; /* redundant with .works-item__media's own radius+clip, but kept for safety */
    transform: scale(1);
    transition: transform 0.6s cubic-bezier(.16,1,.3,1);
  }
  .works-item:hover .works-item__img{ transform: scale(1.045); }

  /* ---- title/meta overlay — moved on top of the image (bottom-left)
     per this task's brief. A dark scrim keeps white text
     legible over any photo; both sit inside .works-item__media so the
     section's existing border-radius clips them the same as the
     photo. ---- */
  .works-item__scrim{
    position: absolute;
    left: 0; right: 0; bottom: 0;
    height: 58%;
    background: linear-gradient(to top, color-mix(in srgb, var(--color-near-black) 60%, transparent), transparent 100%);
    pointer-events: none;
  }
  .works-item__text{
    position: absolute;
    left: 0; right: 0; bottom: 0;
    padding: 22px 22px 20px;
  }
  .works-item__caption{ margin-top: 16px; } /* still used by the placeholder card, which has no photo to overlay */
  .works-item__title{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 17px;
    margin: 0 0 4px;
    color: var(--color-cream);
  }
  .works-item__meta{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 13px;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    color: color-mix(in srgb, var(--color-cream) 50%, transparent);
    margin: 0;
  }
  .works-item__text .works-item__title{
    font-size: 24px;
    color: var(--color-cream);
  }
  .works-item__text .works-item__meta{
    color: color-mix(in srgb, var(--color-cream) 85%, transparent);
  }
  @media (max-width: 640px){
    .works-item__text .works-item__title{ font-size: 20px; }
    .works-item__text{ padding: 18px 18px 16px; }
  }

  /* sixth box — no client yet, kept as an editable placeholder
     rather than substituting a real client into an unused slot. */
  .works-item--placeholder .works-item__media{
    background: transparent;
    border: 1px dashed color-mix(in srgb, var(--color-cream) 28%, transparent);
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .works-item--placeholder .works-item__placeholder-mark{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-size: 13px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: color-mix(in srgb, var(--color-cream) 35%, transparent);
  }

  /* ---- cursor-follower — cherry bomb outline standing in for a
     plain circular dot (see block comment above).
     Fixed + pointer-events:none like the pixel trail; z-index 55
     sits above the trail (50) and below the flame cursor (60). Size/
     easing/timing all live in WORKS_CONFIG in the script. ---- */
  .works-cursor{
    position: fixed;
    top: 0;
    left: 0;
    width: 56px;
    height: 56px;
    pointer-events: none;
    z-index: var(--z-works-cursor);
    opacity: 0;
    color: var(--color-violet);
    mix-blend-mode: exclusion;
    transition: opacity 0.2s ease;
    will-change: transform, opacity;
  }
  .works-cursor svg{ width: 100%; height: 100%; display: block; }
  /* touch / no fine pointer, and prefers-reduced-motion: never shown,
     per this task's explicit "no mouse-following motion" rule under
     reduced motion. Enforced in
     script — the follower is only ever initialized when canHover
     && !reduceMotion, so under either condition it's simply never
     shown (stays at its default opacity:0, no listeners attached). */
  .reduce-motion .works-cursor{ display: none; }

  /* ============================================================
     INTERACTIVE SHOWCASE v2 — own build. Own code throughout: no
     assets, fonts, or third-party carousel library — the horizontal
     loop below is our own hand-rolled implementation.

     ROWS — each row is display:inline-flex,
     white-space:nowrap, its content repeated 3x for a seamless
     loop, sized by its own content (not the 100%-wide row
     container), so it naturally overflows past both screen edges —
     nothing clips it horizontally except the physical viewport.
     Row item: align-items:flex-start (this — NOT a font-size-plus-
     vertical-align trick — is what makes the subtitle read as a
     superscript: title and subtitle are flex siblings pinned to the
     same top edge, and the subtitle is just much smaller, so it
     visually clusters near the top of the tall title). Subtitle is
     ~20% of the title's font-size, CONFIRMED ratio (30px/150px).
     Motion (JS, own design): row position is a continuous function of
     window.scrollY, lerp-smoothed per frame, alternating direction
     per row (odd rows left, even rows right on scroll down, both
     reversing on scroll up), looping infinitely via a repeat-and-
     wrap technique (N repeated copies per row, wrapped every exact
     multiple of one copy's width so the seam is always pixel-
     identical content). Only animates while the section intersects
     the viewport; static under prefers-reduced-motion. This section
     used to be the last thing on the page — briefly needed a filler
     spacer after it just to give scrolling enough room for this
     motion to run through the section's own exit — but the services
     section below now provides that same room as real content
     instead, so the spacer's gone.

     HOVER — text color only (#e6e6e6-equivalent -> #0B0B0F), .2s
     ease-out, no motion on the text itself. Only the exact hovered
     element gets it — a repeated copy of the same brand elsewhere
     stays untouched.

     IMAGE REVEAL — fixed position (not cursor-following), 450ms
     delayed-hide so the outgoing image overlaps the incoming one,
     400ms fade-in (cubic-bezier(.215,.61,.355,1)), the image itself
     300ms opacity + 400ms scaleY(1.1)->1/skewY(5deg)->0 "un-skew"
     (cubic-bezier(.22,.61,.36,1)). The "irregular blob, not a
     rectangle" quality comes from our own clip-path (an SVG blob
     shape, defined once, reused for every image), on top of the
     skew/scale/timing above.
     Own fix: the image layer sits in its own stacking context
     (.showcase has isolation:isolate) with a higher z-index than the
     rows, so a revealed image always paints on top of the row text
     rather than behind it; pointer-events:none on the layer keeps it
     from blocking hover on the rows underneath. The hovered name
     itself then rises one level above THAT (a promoted copy in
     .showcase-active-name, since a z-index on the real item can't
     escape the stacking context its own row-track's `transform`
     creates — see that block comment below), with a second,
     white, clip-path-masked copy of the same name stacked exactly on
     top of it and clipped every frame to the image's own live
     position, so the hovered name reads black on the plain
     background and white wherever it overlaps the photo, split
     exactly at the image's edge.

     VIEW CURSOR — a custom cursor: 8px dot, scales 10x on hover via
     a 0.3s cubic-ish ease-out, "View" label fades in over the same
     0.3s, and its position updates with ZERO lag (duration 0), not
     eased at all — the one part of this whole component that IS
     truly cursor-tracking. Colored flame orange per this task's
     brief, and layered below our flame-hand cursor (z-index) so the
     flame hand stays the visible "primary" cursor, matching this
     task's explicit "flame hand cursor stays visible" instruction.
  ============================================================ */
  .showcase{
    position: relative;
    background: var(--showcase-bg, var(--color-cream));
    color: var(--showcase-text, var(--color-near-black));
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 4vh 0;
    overflow: hidden;
    isolation: isolate; /* own stacking context so rows/image-reveal/View-circle z-index order can't be disturbed by anything outside the section */
  }
  .showcase-rows{
    position: relative;
    z-index: 10;
    display: flex;
    flex-direction: column;
  }
  .showcase-row{
    width: 100%;
    overflow: visible;
    margin: 4px 0;
  }
  .showcase-row-track{
    display: inline-flex;
    white-space: nowrap;
    will-change: transform;
  }
  .showcase-item{
    display: inline-flex;
    align-items: flex-start; /* CONFIRMED — see block comment: this is the actual superscript mechanism */
    padding: 0 12px 3px;
    color: color-mix(in srgb, var(--color-near-black) 10%, transparent);
    text-decoration: none;
    transition: color .2s ease-out; /* CONFIRMED */
    /* own fix ("label bleeding into the next brand name" — real bug,
       live-measured, not the label's own box): THIS element is a
       flex ITEM of .showcase-row-track and, with the browser default
       flex-shrink:1, was being compressed below its own children's
       combined natural width whenever the row's flex math had reason
       to shrink it — .showcase-item__subtitle's own flex-shrink:0
       (needed so IT doesn't collapse, see that rule's comment) then
       refused to shrink along with its now-too-small parent, so the
       label rendered at its full width but poking out past its OWN
       item's box — directly into the next sibling item's space in
       the row. flex-shrink:0 here stops the parent from ever being
       compressed below what its own title+label actually need, which
       is what actually keeps the label inside its own item's
       horizontal footprint — a marquee track is meant to be
       arbitrarily wide anyway, so there's no reason for any item
       here to shrink at all. */
    flex-shrink: 0;
  }
  html.has-flame-cursor .showcase-item{ cursor: none; } /* the View cursor + flame hand take over — see block comment */
  .showcase-item__title{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 800;
    text-transform: uppercase;
    line-height: 1.19; /* CONFIRMED ratio */
    font-size: 64px;
  }
  .showcase-item__subtitle{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.02em;
    font-size: 13px; /* CONFIRMED ~20% of the title size */
    margin-left: 6px;
    /* own follow-up (some categories now run longer, e.g. "Retail
       Media, Design, Social Media & Development"): was a single
       oversized line-height (3.1em) doing double duty as both the
       "superscript" vertical offset AND the row's effective height —
       fine for one line, but a second wrapped line would have grown
       past it and pushed the whole row taller. Split those two jobs
       apart: height stays fixed at the same 3.1em the old line-height
       produced (so row height / marquee alignment is identical either
       way), a real per-line line-height (1.3) governs actual text
       rendering, and flex centers 1 or 2 lines inside that fixed box
       — same resting position as before, now indifferent to line
       count. max-width in em so it scales with the mobile font-size
       steps below without its own overrides.

       own second follow-up ("still 4 lines, still not fixed — the
       abbreviation pass never landed, and the column was only ~14
       characters wide"): the shorter category text ("Dev"/"Social",
       see SHOWCASE_CONFIG.brands) is the first half of this; the
       second is this max-width itself, switched from a 15em guess to
       a literal 30ch — `ch` means "N characters wide at this
       element's own font-size", so it's a direct, exact match for a
       "~30 characters per line" target instead of an approximation
       that drifts with weight/tracking. A real hard cap at 2 lines
       (line-clamp below) replaces the old approach of just hoping
       nothing wrapped past what the fixed 3.1em height could fit —
       long outliers now truncate cleanly instead of visibly
       overflowing the row as a 3rd/4th line. */
    height: 3.1em;
    line-height: 1.3;
    max-width: 30ch;
    white-space: normal;
    display: flex;
    align-items: center;
    flex-shrink: 0; /* own fix, live-measured: THIS element is itself a flex ITEM (its own parent .showcase-item is display:inline-flex) — adding overflow:hidden below (needed for the 2-line hard cap) changes a flex item's automatic minimum size from content-based to 0 per spec, and without a floor the row's huge title text (64px, dominant) squeezed this down to ~40px regardless of max-width. flex-shrink:0 stops that squeeze; max-width above still caps its growth the other direction. Tried display:-webkit-box first (line-clamp's classic "required" pairing) — same ~40px collapse, so it wasn't a box-model issue at all, just this. */
    overflow: hidden; /* hard cap at 2 lines — pairs with -webkit-line-clamp below */
    -webkit-line-clamp: 2; /* modern Chromium/WebKit both support line-clamp on a plain flex/block box, no -webkit-box display needed */
  }
  .showcase-item.is-active,
  .showcase-item:hover{
    color: var(--color-near-black); /* CONFIRMED */
  }
  /* own fix: the real hovered element stays in place (so it keeps
     receiving the mouseenter/mouseleave that drive all of this) but
     renders invisible — opacity, not visibility, so it stays
     hit-testable — while its promoted .showcase-active-name copy
     stands in for it visually, above the image. */
  .showcase-item.is-active{ opacity: 0; }
  @media (max-width: 1600px){ .showcase-item__title{ font-size: 44px; } }
  @media (max-width: 1366px){ .showcase-item__title{ font-size: 32px; } }
  @media (max-width: 680px){
    .showcase-item__title{ font-size: 20px; }
    .showcase-item__subtitle{ font-size: 8px; }
  }
  /* own fix, per real-device report: .showcase's own min-height:100vh
     + justify-content:center (base rule above) vertically centers the
     row stack inside a full viewport-height box — on mobile, where
     the row stack itself is much shorter than 100vh (smaller type,
     fewer rows visible), that leaves a large empty gap above it,
     centered against an equally large gap below. Letting the section
     size to its own content removes both gaps at once. */
  @media (max-width: 767px){
    .showcase{ min-height: auto; padding: 6vh 0; }
    /* own follow-up, reverting an earlier mobile-only rebuild: the
       desktop marquee (rows sliding horizontally, scroll-linked,
       alternating direction, hover/tap-driven black highlight + blob
       swap) is explicitly wanted back on mobile instead of the plain
       vertical list that replaced it — see the removed
       .showcase-mobile-list/-blob rules this replaced, and the
       script's own MOBILE ACTIVATION block for how "active" is now
       decided without a real hover. */
    /* own follow-up ("names ~26-30px, 8-9 rows"): overrides the
       680px rule above (this query is later in source, so it wins at
       any width <=767 including the <=680 overlap) rather than
       shrinking the same marquee text as far as desktop's own tablet
       step did. */
    .showcase-item__title{ font-size: 28px; }
    .showcase-item__subtitle{ font-size: 10px; margin-left: 4px; }
  }

  /* ---- reveal images — fixed position (CONFIRMED, not cursor-following), blob clip-path (own technique) + CONFIRMED skew/scale/timing ---- */
  .showcase-images{
    position: absolute;
    inset: 0;
    z-index: 20; /* above .showcase-rows (10) — image reveal must sit on top of the row text, never behind it */
    overflow: hidden;
    pointer-events: none; /* image layer never blocks hovering the rows underneath it */
  }
  .showcase-image{
    position: absolute;
    top: 50%;
    right: 10%;
    width: clamp(280px, 30vw, 480px);
    height: clamp(280px, 30vw, 480px);
    transform: translateY(-50%);
    opacity: 0;
    visibility: hidden;
    /* CONFIRMED: 0-duration transition with a 450ms DELAY. */
    transition: opacity 0s var(--showcase-hide-delay, .45s), visibility 0s var(--showcase-hide-delay, .45s);
  }
  .showcase-image.is-active{
    opacity: 1;
    visibility: visible;
    z-index: 20;
    transition: opacity var(--showcase-fade-in, .4s) cubic-bezier(.215,.61,.355,1), visibility 0s;
  }
  .showcase-image__img{
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    clip-path: url(#showcaseBlob); /* own technique — see block comment */
    opacity: 0;
    transform: scaleY(var(--showcase-scale-y-rest, 1.1)) skewY(var(--showcase-skew-rest, 5deg));
    will-change: transform;
    transition: opacity var(--showcase-img-fade, .3s) ease-out, transform var(--showcase-img-transform, .4s) cubic-bezier(.22,.61,.36,1);
  }
  .showcase-image.is-active .showcase-image__img{
    opacity: 1;
    transform: scaleY(1) skewY(0deg);
  }
  /* own follow-up ("blob below the rows, centered, ~270px, never
     overlapping names"): the shared .showcase-image is fixed at
     top:50%/right:10% on desktop (see its own base rule + comment
     above, "CONFIRMED, not cursor-following") — repositioned here into
     its own centered row below the marquee instead, sized to spec.
     Placed AFTER the base rules above (own fix: an earlier version of
     this override sat inside the max-width:767px block up near
     .showcase-item's own mobile sizing, BEFORE these same-specificity
     base rules in source order — same cascade-order bug this file has
     hit before, later-in-source wins regardless of media query
     "narrowness", so the base .showcase-images{position:absolute} was
     silently winning over this override every time). .showcase-images
     itself switches out of position:absolute so it takes up real flow
     space (pushing nothing since the section is already a flex column
     — .showcase-rows above it, this below) rather than overlapping the
     rows at inset:0. */
  @media (max-width: 767px){
    .showcase-images{
      position: relative;
      inset: auto;
      height: 300px;
      margin-top: 24px;
      overflow: visible;
    }
    .showcase-image{
      top: 50%;
      left: 50%;
      right: auto;
      width: 270px;
      height: 270px;
      transform: translate(-50%, -50%);
    }
  }
  /* prefers-reduced-motion: images appear with no motion — plain
     instant swap, no delayed-hide overlap, no skew/scale settle. */
  .reduce-motion .showcase-image,
  .reduce-motion .showcase-image__img{
    transition: none !important;
    transform: none !important;
  }

  /* ---- active-name overlay — own fix: promoted copy of the hovered
     name, positioned (JS, every frame) to sit exactly over the real
     (now invisible) item underneath. Lives as a direct child of the
     isolated .showcase — NOT inside any .showcase-row-track, whose
     own `transform` would otherwise cap it at the track's stacking
     level no matter what z-index it's given, trapping it under the
     image regardless. position:fixed + z-index here is resolved
     against .showcase's own isolation:isolate context. Two full-text copies stacked exactly on
     top of each other: __base (black, the default "hovered" color,
     covers the whole name) and __clip (white, clip-path recomputed
     every frame from the live image rect in this element's own local
     coordinates — see JS — so only the part actually overlapping the
     image shows white, with the split landing exactly on the image's
     edge, mid-letter included). Not mix-blend-mode: difference —
     that reads as noise over a colorful photo; an exact geometric
     clip keeps both halves flat, intentional colors. pointer-events:
     none throughout so this layer, sitting above every row, never
     blocks hovering a different name underneath it. */
  .showcase-active-name{
    position: fixed;
    top: 0;
    left: 0;
    z-index: 30; /* above .showcase-images (20), below the flame cursor (60) */
    pointer-events: none;
  }
  .showcase-active-name .showcase-item{
    position: absolute;
    top: 0;
    left: 0;
    /* own fix: unlike a real row item, this clone isn't a descendant
       of .showcase-row-track, so it doesn't inherit that element's
       white-space:nowrap — without this, its shrink-to-fit width
       (computed against .showcase-active-name's own zero-width fixed
       box, since its only children are taken out of flow by their
       own position:absolute) comes out too narrow and the name wraps
       to a second line instead of staying on one. */
    white-space: nowrap;
  }
  .showcase-active-name__base.showcase-item{
    color: var(--color-near-black);
    opacity: 0;
    transition: opacity .15s ease-out;
  }
  .showcase-active-name.is-active .showcase-active-name__base.showcase-item{ opacity: 1; }
  .showcase-active-name__clip.showcase-item{
    color: var(--color-cream);
    opacity: 0;
    /* mirrors .showcase-image's own delayed-hide / fade-in exactly,
       so the white split only appears once (and only for as long as)
       the image itself is actually visible */
    transition: opacity 0s var(--showcase-hide-delay, .45s);
  }
  .showcase-active-name.is-active .showcase-active-name__clip.showcase-item{
    opacity: 1;
    transition: opacity var(--showcase-fade-in, .4s) cubic-bezier(.215,.61,.355,1);
  }
  .reduce-motion .showcase-active-name__base,
  .reduce-motion .showcase-active-name__clip{
    transition: none !important;
  }

  /* own follow-up, explicit request: the View cursor (orange dot +
     "View" label badge) that used to live here is gone entirely —
     nothing in showcase is clickable at launch, and a hover
     affordance on an unclickable element is worse than none. Items
     keep the plain flame-hand cursor only. */

  /* ============================================================
     DOODLES — hand-drawn GIF accents, own build, own pattern.
     Placement/sizing driven entirely by DOODLES_CONFIG in
     the script below; these rules are just the shared mechanics:
     never interactive (pointer-events:none, decorative alt=""), and
     never part of layout flow at their mount point (position:
     absolute against a positioned ancestor) so they can sit next to
     content without pushing it around or being pushed themselves.
  ============================================================ */
  .doodle{
    position: absolute;
    display: block;
    height: auto;
    aspect-ratio: 1 / 1; /* every doodle source is a 600x600 square — fixes rendered height from CSS width alone, so position math never has to wait on image-load to know it */
    pointer-events: none;
    user-select: none;
  }
  /* works section: the heading + its doodle share one flex row
     instead of the doodle being measured/positioned by JS — simpler
     and correct at any heading width/wrap without extra code. */
  .works-heading-row{
    display: flex;
    align-items: center;
    gap: 0.35em;
    font-size: clamp(40px, 6.4vw, 104px); /* same value .works-heading used to carry directly — now shared with the row so the doodle's em-based width (a sibling, not a descendant, of the heading) tracks it too */
  }
  .works-heading-row .works-heading{ margin: 0 0 7vh; } /* moved from .works-heading itself so the row, not the heading alone, carries it */
  .works-heading-row .doodle{
    position: static; /* inline with the heading here, not absolutely placed */
    flex: none;
  }
  /* ---- per-placement size (position for the anchored/corner ones is
     computed in JS — see DOODLES_CONFIG — but size is CSS so each one
     scales with the text it sits next to, per this task's own rule) --- */
  /* ============================================================
     SERVICES — "What we build with you", own build.
     Plain un-pinned rows in normal document flow, each one a
     12-column grid (number / title+subs / paragraph / illustration),
     separated by a thin top-border divider, with a lot of vertical
     breathing room — no sticky, no pin, no slide, no stacking. The
     only motion is a light once-only fade-up per row on scroll
     (CONFIRMED: 0.6s power2.out, opacity 0->1 + 10% Y offset ->0,
     triggered once at 80% viewport, never reversed) — reproduced
     below with a plain IntersectionObserver, no animation library
     needed for something this simple.

     COLOR — own choice, a near-black background so this section
     reads distinct from the light showcase section directly above it.
     data-theme="dark" (not "light") so the cursor trail keeps its own
     default bright color here instead of switching to the dark color
     reserved for light-background sections — same mechanism as every
     other data-theme switch in this file, just the opposite branch of
     it. Every color lives in SERVICES_CONFIG.colors in the script and
     is applied here as CSS custom properties — swap the whole palette
     in one place, not scattered across these rules. Doodles: this
     section being dark flips the light/dark line variant each one
     auto-selects too (see doodleUrl in the script) — off-white lines
     read correctly here without any extra code.
  ============================================================ */
  .services{
    position: relative;
    background: var(--services-bg, var(--color-near-black)); /* own colors, set from SERVICES_CONFIG.colors in the script — see there to swap the whole palette in one place */
    color: var(--services-text, var(--color-cream));
    padding: 8vh 6vw;
  }
  .services-head{
    position: relative; /* own stacking context — .services-heading and .services-heading__preview both position against this */
    padding-bottom: 6vh;
  }
  /* the industries heading/typing row's own font-size, shared by the
     font-size rule and the min-height reservation below so the two
     can never drift out of sync (own fix: duplicating the clamp() in
     two places is exactly how a min-height meant to match a line's
     rendered height quietly stops matching it after the next edit). */
  .services-heading{
    --services-heading-size: clamp(32px, 5vw, 76px); /* CONFIRMED scale (76px) */
    position: relative;
    z-index: 2;
    display: flex;
    flex-wrap: nowrap;
    align-items: baseline;
    column-gap: 0.28em;
    row-gap: 0;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 800; /* CONFIRMED reference uses 400 (regular) — kept at 900 for consistency with every other display heading already established in this file */
    line-height: 0.95; /* own follow-up: sentence case, own request — text-transform removed here too, matching the hero/Selected works change; INDUSTRIES_CONFIG labels below switched to match except CPG, kept as the acronym it is */
    font-size: var(--services-heading-size);
    margin: 0;
    opacity: var(--services-text-opacity, 1);
    text-shadow: 0 2px 16px rgba(0,0,0,.5); /* keeps the type readable over the hover preview image, which sits directly behind it */
    min-height: calc(var(--services-heading-size) * 0.95); /* reserves this row's own height up front — own fix, per explicit request: without this, the row would grow/shrink as the typed word's line-box settles, jumping the services-stage rows below on every letter */
  }
  .services-heading__static{ white-space: nowrap; }
  .services-heading__typed-wrap{
    position: relative; /* anchors .services-heading__ghost behind the typed word */
    display: inline-flex;
    align-items: baseline;
    white-space: nowrap;
    cursor: pointer;
  }
  /* ---- ghost — own follow-up, per explicit request ("place at the
     end of the text vs behind it, get rid of the opacity, keep the
     hover effect, put the [industry] name in/on the image"): was a
     faint, blurred preview centered BEHIND the typed word (hence the
     old name/negative z-index, needed only to stay legible under the
     text it overlapped) — now sits just past the end of the typed
     word instead (left:100% of .services-heading__typed-wrap, which
     is position:relative and sized to the current word, so this
     tracks the word's own growing/shrinking width every frame with no
     script change), always at full opacity/no blur since it no longer
     has text to stay subtle under. The hover/tap "peek" state (see
     the script's own showPreview(), unchanged) still runs — now read
     as a small pop/lift rather than a blur-to-sharp reveal, so hovering
     still visibly does something even though the resting state is
     already sharp. Same shared transition timing as before. ---- */
  .services-heading__ghost{
    position: absolute;
    top: 50%;
    left: 100%;
    margin-left: 0.35em;
    z-index: 1;
    width: clamp(100px, 14vw, 160px);
    height: clamp(70px, 9.5vw, 110px);
    border-radius: 10px;
    overflow: hidden;
    pointer-events: none;
    opacity: 0;
    transform: translateY(-50%) scale(0.92);
    transition: opacity .5s cubic-bezier(0.22, 1, 0.36, 1), transform .5s cubic-bezier(0.22, 1, 0.36, 1);
  }
  .services-heading__ghost.is-shown{
    opacity: 1;
    animation: servicesGhostDrift 8s ease-in-out infinite;
  }
  .services-heading__ghost.is-peek{
    opacity: 1;
    transform: translateY(-50%) scale(1.04);
    animation: none;
  }
  .services-heading__ghost-img{
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
  /* the industry name, own addition ("put the [name] in/on the
     image") — small caption pinned to the thumbnail's own bottom
     edge, same scrim-behind-type convention .works-item__text uses
     elsewhere in this file for a label over a photo. */
  .services-heading__ghost-label{
    position: absolute;
    left: 0; right: 0; bottom: 0;
    padding: 6px 8px;
    background: linear-gradient(to top, rgba(0,0,0,.65), transparent);
    color: var(--color-cream);
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 11px;
    letter-spacing: 0.02em;
    text-transform: uppercase;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  /* own follow-up: industries with no real client yet get no label at
     all — .is-empty (see updateGhost) drops this whole scrim, not
     just the text, so no dark strip lingers over the bare photo. */
  .services-heading__ghost-label.is-empty{ display: none; }
  @keyframes servicesGhostDrift{
    0%, 100%{ transform: translateY(-50%) scale(0.92) translate(0, 0); }
    50%{ transform: translateY(-50%) scale(0.92) translate(3px, -3px); }
  }
  /* .services-heading__typed intentionally carries no color/font
     rules of its own — it inherits every one of them from
     .services-heading above, which is what "the typed word matches
     the heading style exactly" (own explicit request) means in
     practice: one declaration, not a duplicate copy that could
     drift. */
  .services-heading__cursor{
    display: inline-block;
    width: 0.06em; /* own fix, per explicit report: this read as a solid block, not a caret — a thin line instead */
    height: 0.78em;
    margin-left: 0.1em;
    transform: translateY(0.06em);
    background: var(--color-orange); /* CONFIRMED color */
    animation: servicesCursorBlink 1s step-end infinite; /* own choice: a hard on/off step, not a fade — matches a real terminal-style caret rather than a soft pulse */
  }
  @keyframes servicesCursorBlink{
    0%, 49%{ opacity: 1; }
    50%, 100%{ opacity: 0; }
  }
  .services-heading .sr-only{
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip: rect(0,0,0,0);
    white-space: nowrap;
    border: 0;
  }

  /* ---- hover/tap preview — a client photo for whichever industry
     word is currently typed, fading in behind the type (lower
     z-index) and loosely trailing the cursor (see the script's own
     rAF loop for the lag + gentle bob). position:fixed so it's never
     clipped by any section's own overflow, and so its coordinate
     space matches clientX/clientY directly with no offset math. ---- */
  .services-heading__preview{
    position: fixed;
    top: 0;
    left: 0;
    z-index: 15;
    width: 220px;
    height: 150px;
    pointer-events: none;
  }
  .services-heading__preview-inner{
    width: 100%;
    height: 100%;
    border-radius: 10px;
    overflow: hidden;
    opacity: 0;
    transform: scale(0.9);
    box-shadow: 0 20px 48px rgba(0,0,0,.5);
    transition: opacity .3s ease, transform .3s ease; /* at-rest (fade-out) speed — own choice, not itself spec'd */
  }
  .services-heading__preview.is-visible .services-heading__preview-inner{
    opacity: 1;
    transform: scale(1);
    transition: opacity .5s cubic-bezier(0.22, 1, 0.36, 1), transform .5s cubic-bezier(0.22, 1, 0.36, 1); /* CONFIRMED: grows in over ~500ms with this easing */
  }
  .services-heading__preview-img{
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  @media (prefers-reduced-motion: reduce){
    .services-heading__cursor{ display: none; } /* static word swap only, per spec — no blinking caret either */
    .services-heading__preview-inner{ transition: opacity .25s ease; transform: none !important; } /* fades rather than scales, per spec */
    .services-heading__ghost{ transition: opacity .25s ease; transform: translateY(-50%) !important; animation: none !important; } /* no drift, no auto-peek (see script), no scale motion even on manual hover — same "fade, don't move" rule as .services-heading__preview-inner above */
  }

  @media (max-width: 991px){
    .services-heading{
      --services-heading-size: clamp(28px, 8vw, 37px); /* CONFIRMED scale (37px) */
      flex-wrap: wrap;
      min-height: calc(var(--services-heading-size) * 0.95 * 2 + 6px); /* two lines now that the typed word wraps under the static lead-in */
    }
    .services-heading__typed-wrap{ width: 100%; } /* forces it onto its own line under the static lead-in once it can't fit beside it, per explicit request */
    /* own fix: the ghost now tracks the typed word's own right edge
       (left:100% of .services-heading__typed-wrap — see that rule's
       own comment), which only lines up when the wrap sizes to fit
       the word. The width:100% override just above stretches the wrap
       to the full row so the (centered) word no longer sits flush
       with the wrap's own right edge, so the ghost would float off to
       the right, disconnected from the text at this breakpoint.
       Simplest correct fix: hidden here too, same as the phone
       breakpoint below, rather than solving centered-text-vs-full-
       width-wrap positioning for one in-between tablet range. */
    .services-heading__ghost{ display: none; }
  }
  @media (max-width: 767px){
    .services-heading{ justify-content: center; text-align: center; } /* own follow-up ("center both lines so it's balanced"): the static "What we build for" line was left-aligned while the typed word below it was already centered */
    .services-heading__typed-wrap{ justify-content: center; }
    .services-heading__ghost{ display: none; } /* own follow-up ("remove the blurred ghost image behind the heading on mobile only"): desktop keeps its hover/auto-peek ghost, this only hides it below the phone breakpoint */
  }

  .services-stage{ position: relative; }

  /* ---- one row — 12-col grid, number/title+subs/
     paragraph/illustration, 1px divider top border, 32px/128px
     top/bottom padding (own choice: kept modest so it reads
     generously without ballooning the section's total scroll length
     across 6 rows). fade-up class is added once by IntersectionObserver
     (see the script) and removed entirely under reduced-motion. ---- */
  .service-row{
    position: relative; /* positioning context for the full-bleed ::before hover backdrop below */
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    column-gap: 16px;
    align-items: start;
    padding: 28px 0 96px;
    border-top: 1px solid var(--services-divider, color-mix(in srgb, var(--color-cream) 15%, transparent));
    color: inherit;
    opacity: 0;
    transform: translateY(24px);
    transition: opacity .6s cubic-bezier(.25,.1,.25,1), transform .6s cubic-bezier(.25,.1,.25,1);
  }
  .service-row.is-visible{ opacity: 1; transform: translateY(0); }
  .service-row:first-child{ border-top: none; } /* CONFIRMED-adjacent: the section itself sits right below the heading's own divider (padding-bottom above), no need for a second line directly under it */
  /* ---- full-bleed hover backdrop — the row's own grid stays inside
     the section's 6vw side padding (so text/doodle positions don't
     move), but the hover/focus tint should read as a full-width band
     edge-to-edge, ignoring that padding. Classic full-bleed-within-a-
     padded-container trick: a ::before stretched to 100vw and
     re-centered on the viewport with the 50vw/-50vw offset pair, sat
     behind the row's own content (z-index -1) since .service-row is
     itself the positioning context (position: relative above). ---- */
  .service-row::before{
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: 50%;
    width: 100vw;
    margin-left: -50vw;
    z-index: -1;
    background: transparent;
    transition: background-color .3s ease;
    pointer-events: none;
  }
  .service-row:hover::before,
  .service-row:focus-visible::before{
    background: color-mix(in srgb, var(--color-cream) 6%, transparent); /* whole row is clickable now — a faint lightening against the near-black section background reads as hover/focus feedback without needing a separate button */
  }
  .service-row:focus-visible{
    outline: 2px solid var(--color-orange);
    outline-offset: -2px;
  }

  .service-row__number{
    grid-column: 1 / 2;
    grid-row: 1;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: clamp(14px, 1vw, 17px); /* scale ~16px — hierarchy here is color, not size */
    color: var(--services-number, var(--color-orange)); /* own choice: a small orange accent */
  }
  .service-row__titlecol{
    grid-column: 2 / 4; /* CONFIRMED ~1.6/12 columns */
    grid-row: 1;
  }
  .service-row__title{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 800; /* CONFIRMED reference: 400, same size as the number — kept bold for legibility at this size in our own type system */
    font-size: clamp(14px, 1vw, 17px); /* CONFIRMED scale (16px), same as the number */
    line-height: 1.3;
    margin: 0 0 0.6em;
  }
  .service-row__subs{
    list-style: none;
    margin: 0;
    padding: 0;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: clamp(14px, 1vw, 17px); /* CONFIRMED: same 16px as the title — hierarchy is color only */
    line-height: 1.6; /* CONFIRMED: stacked one per line */
    color: var(--services-subs, color-mix(in srgb, var(--color-cream) 55%, transparent)); /* CONFIRMED: this is the only rule touching .service-row__subs — no other selector overrides it (checked) */
  }
  .service-row__para{
    grid-column: 5 / 10; /* CONFIRMED ~col5-10.9, roughly half the row */
    grid-row: 1;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 400;
    font-size: clamp(20px, 2vw, 34px); /* CONFIRMED scale (34px) */
    line-height: 1.18; /* CONFIRMED (40px/34px) */
    margin: 0;
  }
  /* ---- animated text fill — each word starts dim and lights up to
     full color as the row's own scroll-fill progress reaches it (see
     the FILL block in the script below); color itself lives on the
     word, not the paragraph, since the fill state is per-word. ---- */
  .service-row__word{
    color: color-mix(in srgb, currentColor 45%, transparent); /* dim state — currentColor still resolves through .service-row__para to var(--services-text) */
    transition: color .35s ease; /* own choice: a soft crossfade rather than a hard cut, so the fill reads as continuous even though it's driven by discrete per-word class toggles */
  }
  .service-row__word.is-filled{ color: currentColor; }
  .service-row__media{
    grid-column: 10 / 13; /* right side, clear of the paragraph's own 5/10 track — CSS grid never lets the two overlap */
    grid-row: 1;
    height: 280px; /* own fix, per explicit report ("sitting high in the section"): was 422px (~1.6x the original 264px) — both this box and the paragraph start at the same grid-row top, so centering *within* the old, much-taller box landed well below the paragraph's own center; shortening the box to roughly a typical paragraph's height is what actually brings the two centers close */
    display: flex;
    align-items: center; /* was flex-start + a -60px pull, which read as glued to the top of the row instead of sitting alongside the paragraph */
    justify-content: center;
  }
  .service-row__media .doodle{
    position: static; /* own fix: overrides the shared .doodle rule's position:absolute — without this it positions against some further-up ancestor instead of centering in this flex panel */
    width: 70%; /* own fix, per explicit request: 30% smaller than the previous 100% fill */
    max-height: 100%;
    object-fit: contain;
    pointer-events: none;
  }

  @media (max-width: 991px){
    /* own choice: let every part of the row go full-width and stack
       (number, title+subs, paragraph, illustration, in that document
       order) — the most natural reading order at this breakpoint. */
    .services-heading{ font-size: clamp(28px, 8vw, 37px); } /* CONFIRMED scale (37px) */
    .service-row{
      grid-template-columns: 1fr;
      row-gap: 2.5vh;
      padding: 24px 0 48px; /* CONFIRMED scale (64px desktop-equivalent, tightened a touch for 6 rows) */
    }
    .service-row__number, .service-row__titlecol, .service-row__para, .service-row__media{
      grid-column: 1 / -1;
      grid-row: auto;
    }
    .service-row__media{ height: 40vw; max-height: 220px; margin-top: -16px; justify-content: flex-start; align-items: center; } /* own override, smaller than desktop's -60px — mobile stacks the doodle directly below the paragraph in normal flow, so too big a pull here would overlap the paragraph text */
    .service-row__media .doodle{ width: auto; height: 70%; } /* own fix, per explicit request: 30% smaller than the previous 100% fill, matching the desktop change */
  }
  @media (max-width: 479px){
    .service-row__para{ font-size: clamp(20px, 8vw, 26px); } /* CONFIRMED-scale fluid clamp */
  }
  /* own fix, per real-device report: the 991px override above
     left-aligns the doodle (justify-content:flex-start) inside its
     now-full-width media box — reads as stuck to the left edge rather
     than sitting under the paragraph as a centered illustration.
     Centered here, and sized down further (~40% smaller than the
     991px breakpoint's own 70% height, i.e. ~42%) per report. */
  @media (max-width: 767px){
    .service-row__media{ justify-content: center; }
    .service-row__media .doodle{ height: 42%; }
  }

  /* own addition: base (desktop-safe) rule for the accordion-only
     title-row wrapper (see the script's own BUILD/ACCORDION blocks) —
     display:contents makes it invisible to layout on desktop
     (title/toggle render exactly as if the wrapper didn't exist), and
     the toggle icon is hidden outright until the max-width:767px
     override below shows it. */
  .service-row__title-row{ display: contents; }
  .service-row__toggle{ display: none; }

  /* ============================================================
     SERVICES ACCORDION — mobile only, matching the supplied
     mobile-design spec (03-industries-services.png) exactly: a
     collapsed row is just its number + title + toggle icon on one
     compact line; the one expanded row (script defaults to "01
     Branding", see ACCORDION block) shows sub-list + doodle side by
     side, paragraph below spanning full width. 300ms ease per spec,
     via grid-template-rows on the collapsible parts (0fr -> 1fr is
     the modern no-JS-measurement way to animate to/from an intrinsic
     "auto" height).
  ============================================================ */
  @media (max-width: 767px){
    .service-row{
      display: block; /* overrides the 991px block's own grid for the outer row — the accordion's own internal grid (expanded state, below) only applies to the parts that need side-by-side placement */
      padding: 0;
      border-top: 1px solid var(--services-divider, color-mix(in srgb, var(--color-cream) 15%, transparent));
      cursor: pointer; /* row is a real tap target here (accordion toggle) — desktop has no click action, so this stays mobile-only */
    }
    .service-row:last-child{ border-bottom: 1px solid var(--services-divider, color-mix(in srgb, var(--color-cream) 15%, transparent)); }
    .service-row__number{ display: none; } /* folded into the title-row's own "01" prefix below instead of its own grid cell — simpler single-line collapsed header */
    .service-row__title-row{
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 16px;
      padding: 20px 0;
      min-height: 44px;
    }
    /* own fix: ::before on the FLEX container itself (.title-row)
       becomes its own flex item, so space-between spread it apart from
       the title as a 3rd item instead of prefixing it — "05" landed
       pinned to the far left with "Email Marketing" centered between
       it and the toggle, not read together as "05 Email Marketing".
       Moved the ::before onto the title <h3> itself (not a flex
       container) so it renders as ordinary inline content directly
       before the title text — title-row still has exactly 2 flex
       items (title, toggle), so space-between correctly pushes only
       the toggle to the far right. */
    .service-row__title::before{
      content: attr(data-number) " ";
      color: var(--services-number, var(--color-orange));
    }
    /* own bump: desktop's 14-17px (hierarchy there is color, not
       size — see that rule's own comment) reads as too small to serve
       as a mobile collapsed row's own primary, tappable header text;
       sized up to something legible/tappable while still compact. */
    .service-row__title{ font-size: 18px; margin: 0; }
    .service-row__toggle{
      display: block;
      position: relative;
      flex-shrink: 0;
      width: 20px;
      height: 20px;
    }
    .service-row__toggle::before,
    .service-row__toggle::after{
      content: "";
      position: absolute;
      top: 50%; left: 50%;
      background: currentColor;
      transform: translate(-50%, -50%);
    }
    .service-row__toggle::before{ width: 14px; height: 2px; }
    .service-row__toggle::after{ width: 2px; height: 14px; transition: transform .3s ease; }
    .service-row.is-expanded .service-row__toggle::after{ transform: translate(-50%, -50%) rotate(90deg) scaleX(0); } /* "-" only once the "|" stroke has rotated flat and shrunk to nothing — a plain cross-fade read as a flicker at this size */

    /* collapsible content — own fix: .service-row__media is a direct
       child of .service-row itself (a SIBLING of .titlecol, not
       nested inside it — see the DOM order in the script's BUILD
       block, kept that way because desktop's own 12-col grid places
       every one of these as independent direct grid items). Giving it
       grid-column placement scoped to .titlecol's own internal grid
       therefore did nothing (a grid item's placement only applies
       within its OWN direct parent's grid) — it just rendered at full
       block width. Simplest correct fix, not a side-by-side layout:
       titlecol (header + sub-list) stacks above a centered, modestly
       sized doodle, both a plain block-flow, no grid needed. Each
       collapsible part gets its own grid-rows 0fr/1fr animator (the
       modern no-JS-measurement way to animate to/from an intrinsic
       "auto" height) so the whole group's height transitions smoothly
       instead of a hard show/hide. */
    .service-row__titlecol{ display: block; } /* NOT the collapse animator — it also holds title-row, which must always stay visible; the sub-list alone (below) is what collapses */
    /* own fix: the grid-rows 0fr/1fr trick (used for media/para/
       viewmore below) needs exactly ONE child so the whole group
       collapses as a single row — .service-row__subs is a <ul> with
       up to 8 <li> children, each becoming its OWN implicit grid row
       by default (grid-auto-rows), so only the first of eight rows
       ever collapsed and the other seven still added their natural
       height. max-height + JS-measured scrollHeight (see the
       ACCORDION script block) is the reliable way to animate an
       actual multi-child list's height instead. */
    .service-row__subs{
      max-height: 0;
      overflow: hidden;
      transition: max-height .3s ease;
    }
    /* own fix, per explicit bug report ("collapsed rows still full
       height, big empty black blocks under each title"): this used
       grid-template-rows:0fr/1fr at first, but measured WebKit
       rendering it at a stubborn 140px on every
       collapsed row regardless. Traced further than a simple
       cascade/specificity issue: with max-height:0 + overflow:hidden
       applied to this flex container, WebKit computes the CHILD
       doodle's own height as 0px too (confirmed by temporarily
       clearing max-height/overflow and watching the child jump back
       to its real 140px) — not just visual clipping, an actual
       collapse of the child's used size. That makes a JS
       scrollHeight measurement (the technique .service-row__subs/
       .service-row__para use below, both fine since their content is
       genuinely variable) impossible here: reading scrollHeight while
       still collapsed always returns 0, chicken-and-egg. But this
       child's height is a fixed constant (140px, set right below),
       not something that needs measuring — so the expanded state
       just targets that same constant directly in CSS instead. */
    .service-row__media{
      max-height: 0;
      transition: max-height .3s ease, opacity .3s ease;
      overflow: hidden;
      opacity: 0;
      margin-top: 0;
      justify-content: center;
    }
    .service-row__media .doodle{ height: 140px; width: auto; }
    /* own fix, same root cause as .service-row__subs above:
       .service-row__para's children are several .service-row__word
       <span> elements (one per word, for the scroll-fill effect) —
       display:grid on a multi-element-child parent gives each of them
       its own implicit row/column, so grid-template-rows:0fr only
       collapsed the FIRST of many rows and every other word still
       added its own line height, reading as "one word per line".
       max-height + JS-measured scrollHeight (same technique as
       .service-row__subs, see the script's own ACCORDION block). */
    .service-row__para{
      max-height: 0;
      overflow: hidden;
      opacity: 0;
      padding-bottom: 32px; /* own follow-up ("open row's paragraph sits right on the divider below it"): included in the base rule (not just .is-expanded) so the JS scrollHeight measurement below picks it up automatically */
      transition: max-height .3s ease, opacity .3s ease;
    }
    .service-row.is-expanded .service-row__subs{ padding-bottom: 12px; } /* max-height itself is set inline by the script (per-row measured scrollHeight), not here */
    .service-row.is-expanded .service-row__media{ max-height: 140px; opacity: 1; margin: 12px 0; }
    .service-row.is-expanded .service-row__para{ opacity: 1; margin-top: 0; } /* max-height itself is set inline by the script, not here — see .service-row__subs' own comment */
  }

  /* ---- prefers-reduced-motion: rows are simply visible, no fade/
     translate at all — matches this task's own explicit rule. ---- */
  @media (prefers-reduced-motion: reduce){
    .service-row{ opacity: 1; transform: none; transition: none; }
  }

  /* ============================================================
     EVERY SITUATION — own build, several passes deep. Card sizing
     (~3-4 visible, full-height portrait columns); the hover — a
     background swap behind the icon plus a rounded pill sliding up
     from the bottom (name, desc, circular arrow) — and the
     scroll-driven horizontal advance below are this task's own
     build. The headline/subhead layout (huge light-weight
     wide-tracked headline, small narrow subhead pinned to the far
     right, no eyebrow label) is this task's own explicit spec.

     One live headline (see .situations-headline). own explicit
     request: the type-in/delete effect (see the HEADLINE
     comment further down in the script) is reserved for the default
     headline only now — card-to-card swaps via hover/scroll go back
     to this section's own original per-letter dim-to-lit ease (see
     .situations-headline__text .ltr below and setHeadlineLetters() in
     the script). Card copy lives in SITUATIONS_CONFIG in the script
     below.

     data-theme="light": cream background, near-black text, matching
     this task's own explicit spec — same mechanism as every other
     data-theme switch in this file.
  ============================================================ */
  .situations{
    position: relative;
    height: 100vh; /* JS inflates this to 100vh + extra scroll room once .is-pinned is added — see the SCROLL block in the script. Plain 100vh is also the whole story under reduced motion / narrower than MOTION_MIN_WIDTH, where that class never gets added. */
    color: var(--color-near-black);
  }
  .situations-sticky{
    position: relative;
    height: 100vh; /* NOT 100% — .situations is the tall scroll-spacer once pinned (see its own comment above), so a percentage here would resolve against that huge height instead of the viewport */
    display: flex;
    flex-direction: column;
    background: var(--color-cream);
    padding: 8vh 6vw;
    /* ---- INTRO — own explicit request: the section now opens on a
       full-screen moment (huge headline + the seal badge, cards
       hidden) before shrinking down into today's compact layout as
       the user keeps scrolling. 1 = fully shrunk/compact (today's
       look, and the default here so anything that never gets driven
       by the SCROLL script below — reduced motion, narrower than
       MOTION_MIN_WIDTH — just renders the compact layout it always
       has). The SCROLL script sets this every frame during the new
       intro phase (see measure()/applyScroll() below), counting DOWN
       from 1 to 0 as the user scrolls further INTO the intro range —
       0 is the full-screen open. .situations-headline's font-size and
       .situations-copy's margin-top below read this directly;
       .situations-seal has its own matching scale(). --situations-
       cards-opacity is a separate property (not just 1 - progress)
       because the cards fade in over only the LAST portion of the
       intro range (see setIntroProgress()) — the headline settles
       into place first, then the cards row appears, rather than
       everything happening at once. ---- */
    --situations-intro-progress: 1;
    --situations-cards-opacity: 1;
  }
  /* ---- pin — while motion is enabled (fine pointer, >=MOTION_MIN_
     WIDTH, no reduced-motion), the script toggles this inner
     element's position directly (fixed while scrolling through the
     extra height .situations gets, then absolute once that range is
     spent) rather than using CSS position: sticky — this file sets
     `overflow-x: hidden` on html/body (see near the top of this
     stylesheet, kept for other sections' full-bleed tricks), and per
     spec that silently promotes overflow-y to auto too, which makes
     sticky lock onto body's own (non-scrolling) box instead of the
     real viewport. fixed/absolute aren't affected by an ancestor's
     overflow, only by transform/filter/contain creating a new
     containing block — none of which exist up this chain — so this
     is the robust option here. Still no scroll-jacking either way:
     native scroll is never prevented, this only repositions an
     element in response to it. ---- */
  /* ---- typewriter headline — own explicit request: replaces the
     crossfade-stack-of-prebuilt-variants system this used to be with
     a type/delete effect (see the matching constants in the script
     below). One
     live headline element now, not one per card — the script types
     and deletes its own text content instead of crossfading between
     separate DOM nodes, so there's no longer a multi-child stack to
     grid-area on top of itself. grid-template-columns still reserves
     the same second-column width this always has, for the same
     reason: keeps the headline at its own narrower wrap instead of
     running the full section width, leaving the corner free for
     .situations-seal to float over without affecting layout height —
     see .situations-seal's own comment. ---- */
  .situations-copy{
    position: relative;
    display: grid;
    grid-template-columns: 1fr minmax(180px, 260px);
    align-items: start;
    gap: 40px;
    margin-bottom: 56px;
    flex-shrink: 0;
    /* INTRO — own explicit request: pushes the whole copy block
       (just the headline — .situations-seal is a separate sibling
       with its own scale()) further down the sticky's flex column as
       --situations-intro-progress counts down toward 0, so a huge
       headline reads as roughly screen-centered rather than pinned
       to the top edge under the 8vh padding. Plain margin (not
       transform) on purpose — .situations-cards-viewport below is
       flex:1 with min-height:0, so it just absorbs the shrinking
       remainder rather than needing anything explicit here to make
       room. */
    margin-top: calc(14vh * (1 - var(--situations-intro-progress, 1)));
  }
  .situations-headline{
    grid-column: 1;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 400; /* CONFIRMED: light/regular, not this file's usual bold display weight */
    line-height: 0.95;
    letter-spacing: 0.02em; /* CONFIRMED: wide tracking */
    /* INTRO — own explicit request: grows toward a full-screen size
       as --situations-intro-progress counts down toward 0 (see
       .situations-sticky's own comment for what drives it). A real
       font-size change (not transform: scale()) on purpose — at 0
       this text needs to REWRAP into fewer, bigger lines within
       .situations-copy's own grid-column-1 width, not visually
       overflow past the viewport edge the way scaling an
       already-wrapped block would. Each clamp() argument is its own
       calc() blending today's compact value (at progress 1) up to a
       bigger one (at progress 0) — clamp()'s min/preferred/max can
       each be an independent expression like this. */
    /* own follow-up (ragged wrap at the largest intro size): the max
       end of this clamp was 140px (84+56) at full intro progress —
       measured live, that's the exact point the new (longer) default
       headline stops fitting 3 clean lines within its own max-width
       below and drops to a ragged 4th line. 130px (84+46) is the
       largest size that still holds 3 lines there; left as its own
       named delta so it's obvious this got tuned, not just copied. */
    font-size: clamp(
      calc(36px + 20px * (1 - var(--situations-intro-progress, 1))),
      calc(5.5vw + 3.5vw * (1 - var(--situations-intro-progress, 1))),
      calc(84px + 46px * (1 - var(--situations-intro-progress, 1)))
    );
    /* own fix (real collision, caught live): .situations-seal is a
       SIBLING, not a grid item — see its own comment — so the grid's
       own minmax(180px,260px) second column doesn't actually reserve
       real space for it; that column is layout fiction the seal
       ignores entirely (it's position:absolute, sized/positioned off
       .situations-sticky, not this grid). Explicit max-width here is
       the one real guard against overlapping it AND (with
       text-wrap:balance unusable — see below) the thing that actually
       controls where this specific headline's own lines break: with
       text-wrap:balance dropped, plain greedy wrap fills each line as
       full as it can before wrapping, which can leave a short/orphan
       final line depending on exactly how much width is on offer.
       52vw is the live-measured width (bisected against the real
       rendered text, not guessed) where greedy wrap happens to land
       on "A proven process for" / "every problem, pivot" / "and plot
       twist." — the exact balanced 3-line target — at every viewport
       this environment could render (1728px down to 992px; see the
       PROCESS HEADLINE WRAP verification notes for the full table).
       A pure vw value (not a flat px offset) so it scales at the same
       rate as both the badge's own vw-based position and this
       headline's own vw-based font-size, instead of drifting out of
       proportion at narrower widths the way an earlier flat-offset
       version of this did.

       own second follow-up ("still broken — root cause: font-size is
       too large relative to the container at narrow/mid widths, so no
       wrapping strategy can produce 3+ word lines"): the real bug was
       never text-wrap — it's that this max-width and the clamp()'d
       font-size above scaled at DIFFERENT rates (a flat 52vw vs a
       min/preferred/max clamp with its own independent px and vw
       terms), so the characters-per-line ratio genuinely drifted
       across widths instead of staying constant, and at some widths
       (measured live: ~768–900px, where the old 900px-breakpoint
       font-size query took over but the max-width override only
       started at 767px, leaving a seam) there just isn't enough width
       for even 2 words at that font-size — no wrap algorithm can fix
       a container that's genuinely too narrow for its own text. `ch`
       is defined AS "N characters wide at the current font-size", so
       it scales with font-size automatically (including through the
       intro clamp() above and both mobile breakpoints below) instead
       of needing to be kept in lockstep with it by hand — this is
       what actually holds the target ~18–26 chars/line everywhere,
       confirmed by a 320–1920px sweep (40px steps) in both Chromium
       and WebKit, not spot-checked. */
    max-width: 23ch;
    margin: 0;
    color: var(--color-near-black);
    /* own follow-up: re-added text-wrap:balance, per explicit request,
       now that max-width/font-size actually stay proportional at every
       width (the earlier "5 narrow lines even at max-width:2000px"
       failure was very likely balance repeatedly re-measuring against
       a max-width that was ITSELF still shifting during that same
       layout pass — vw and clamp() terms recompute relative to the
       viewport/font-size on every reflow balance triggers, so an
       unstable target width could easily produce a wrong, unstable
       result. ch resolves once against the element's own computed
       font-size and doesn't move again mid-layout, which is the
       likely reason this is stable now where it wasn't before. Confirmed
       clean at every width in the same sweep. */
    text-wrap: balance;
    /* own fix, per explicit report: reserve room for 3 lines ALWAYS
       (was 2 — the new default headline balances to 3 at most widths
       now) so everything below (the cards viewport) doesn't shift
       up/down as the typewriter cycles between short and long
       headlines. em-based so it tracks the clamp()'d font-size at
       every breakpoint. */
    min-height: calc(0.95em * 3);
  }
  /* ---- cursor — own choice: a "|" character blinking on a 1000ms
     cycle. own fix, per explicit request:
     card-to-card swaps no longer type at all (see the .ltr ease
     below), so the cursor now only shows while .situations-headline
     carries .is-typewriter — the default headline only, toggled in
     showVariant() in the script. Gated to the same motion query the
     rest of this file uses for anything purely decorative — under
     reduced motion the cursor just sits static (no blink), matching
     setHeadlineText() below also skipping the type animation entirely
     there and swapping text instantly. ---- */
  .situations-headline__cursor{
    display: none;
    margin-left: 0.03em;
  }
  /* own fix, per explicit bug report ("the typing caret still renders
     after 'plot twist.' once typing finishes — it should disappear on
     completion, or blink only while typing"): was keyed off
     .is-typewriter alone, which stays on the default headline
     permanently (see showVariant() in the script) — nothing ever
     turned the cursor back off once a type run finished. .is-typing
     is the new, narrower signal (added in setHeadlineText() right
     before typeStep() starts, removed the instant it reaches the end
     of the text — see the script) — the cursor now only shows while a
     type animation is actually in flight. */
  .situations-headline.is-typewriter.is-typing .situations-headline__cursor{
    display: inline-block;
  }
  @media (prefers-reduced-motion: no-preference){
    .situations-headline__cursor{ animation: situationsCursorBlink 1s step-end infinite; }
  }
  @keyframes situationsCursorBlink{ 50%{ opacity: 0; } }
  /* ---- letter ease — own explicit request: reverts card-to-card
     headline swaps to this section's original per-letter dim-to-lit
     ease (the typewriter above is reserved for the default headline
     only now). One span per word (so a line only ever breaks between
     words, same convention the statement section's own
     buildStatementText() uses) and, inside each word, one span per
     letter — .ltr starts dim, .is-lit is added after a forced reflow
     in setHeadlineLetters() below so the same class can be removed
     and re-added to replay the ease on every call rather than only
     the first. transition-delay is set once per letter, from its own
     position among ALL the letters in the headline (spaces don't
     consume a position — see buildHeadlineLetters() in the script),
     so later letters ease in later with no per-frame JS driving it. */
  .situations-headline__text .word{
    display: inline-block;
  }
  .situations-headline__text .ltr{
    display: inline-block;
    color: color-mix(in srgb, var(--color-near-black) 18%, transparent);
  }
  @media (prefers-reduced-motion: no-preference){
    .situations-headline__text .ltr{
      transition: color 0.45s ease-out;
    }
  }
  .situations-headline__text .ltr.is-lit{
    color: var(--color-near-black);
  }

  /* ============================================================
     SEAL — circular rotating badge, own explicit request: "A
     SOLUTION FOR EVERY SITUATION" repeated around a ring with dot
     separators, our own flame-hand cursor artwork reused (unmoving)
     at the center. Sits top-right, next to the headline, in the
     same corner the subhead used to occupy before it was removed
     (see .situations-copy's own comment) — sized up significantly
     (was 84px) per explicit follow-up now that it's the only thing
     in that corner.

     Own fix, per explicit correction: this was briefly a normal flex
     item stacked above .situations-copy, which meant growing it grew
     the whole flex column and shrank the card row below — exactly
     the "cards got shorter, top section got taller" regression
     reported. position:absolute takes it out of .situations-sticky's
     flex flow entirely (anchored to that flex container's own
     padding box, which is the same "relative" it already was, just
     no longer flex-participating) so its size can never affect the
     copy block's or the card row's own height — it only ever
     overlaps the corner space the grid's second column already
     reserves, visually reading as sitting right next to the
     headline, same as the subhead once did.

     own fix, per explicit bug report ("badge still overlaps the
     headline on mobile"): this base rule used to be declared AFTER
     the @media(max-width:900px)/(max-width:767px) overrides below
     that are supposed to shrink/reposition it for mobile — same
     selector, same specificity, so being LAST in source order made
     THIS rule win regardless of viewport width, silently defeating
     both media queries (confirmed live: a 390px-wide iPhone was
     still getting the full 200x200 absolute-positioned desktop
     badge). Moved above them so the mobile-first cascade — base rule,
     then narrower-and-narrower overrides — actually applies in the
     order the file's own convention expects everywhere else. ---- */
  .situations-seal{
    position: absolute;
    /* own fix, per explicit correction: an absolutely positioned
       child's inset is measured from its containing block's padding
       EDGE, not its content edge — so top:0;right:0 ignored
       .situations-sticky's own 8vh 6vw padding entirely and sat
       flush with the true section edge instead of lining up with
       everything else in it (the headline, the card row's own right
       border). right: 6vw still matches that padding for the same
       reason; top is now 5vh (own choice, per explicit follow-up,
       down from matching the padding at 8vh) to sit a little higher,
       closer to the section's own top edge than the headline row
       below it starts. */
    top: 5vh;
    right: 6vw;
    width: 200px;
    height: 200px;
    /* INTRO — own explicit request: grows from its own top-right
       corner (transform-origin below matches the top:/right: anchor
       above, so it grows further INTO that corner rather than
       drifting) as --situations-intro-progress counts down toward 0.
       transform, not a width/height change, since this is already
       taken out of flow entirely (see the comment above) — nothing
       else needs to reflow around it either way. */
    transform-origin: top right;
    transform: scale(calc(1 + 0.5 * (1 - var(--situations-intro-progress, 1))));
  }

  @media (max-width: 900px){
    .situations-copy{ grid-template-columns: 1fr; gap: 16px; }
    /* own fix ("Process section doesn't work on mobile... same
       choreography as desktop"): was a flat clamp(28px,9vw,48px),
       ignoring --situations-intro-progress entirely — harmless at
       this breakpoint's own tablet width (no pin ever runs there,
       progress stays its default 1, same numbers either way) but the
       one thing standing between MOBILE_INTRO below and an actual
       fullscreen-sized headline once it starts driving progress
       toward 0. Same calc()-per-clamp-argument shape as the base
       (desktop) rule above, just with smaller deltas sized to this
       breakpoint's own compact end (28/9vw/48 instead of 36/5.5vw/84)
       — at progress 1 every added term is *0 and this evaluates to
       the exact original flat clamp, so nothing changes above 767px
       where no pin ever touches the property.

       own follow-up, explicit feedback ("the initial statement needs
       to be bigger"): deltas raised again (10/5vw/20 -> 18/9vw/34) —
       roughly 30% bigger at the fullscreen-open end (progress 0) than
       the first pass, e.g. ~54px -> ~70px at 390px wide. Still a
       clamp(), so it stays legible/non-overflowing across the phone
       width range this breakpoint covers, and the progress-1 (compact)
       end is untouched either way. */
    .situations-headline{
      font-size: clamp(
        calc(28px + 18px * (1 - var(--situations-intro-progress, 1))),
        calc(9vw + 9vw * (1 - var(--situations-intro-progress, 1))),
        calc(48px + 34px * (1 - var(--situations-intro-progress, 1)))
      );
    }
    /* own fix: the seal is a fixed 200x200px badge, positioned
       absolutely in the section's own top-right corner — at phone
       widths that's large enough to overlap the headline text below
       it once .situations-copy stacks to one column. Shrinking it
       (and pulling its own corner inset in to match) keeps it
       readably out of the headline's way. */
    .situations-seal{ width: 120px; height: 120px; top: 3vh; right: 5vw; }
  }
  /* own fix, per live-device verification (still overlapping the
     headline after the previous "push down 16vh" attempt — a fixed
     vh guess can't account for how many lines the headline actually
     wraps to at a given phone width/font-scale/zoom, so it kept
     overlapping on real content). Genuinely reliable fix: take it out
     of absolute positioning entirely and place it in NORMAL FLOW,
     using order on .situations-sticky (already display:flex;
     flex-direction:column — see that rule) to render it AFTER
     .situations-copy regardless of DOM order, instead of guessing a
     vertical offset that has to clear unpredictable text height. */
  @media (max-width: 767px){
    .situations-copy{ order: 1; }
    /* own follow-up ("remove the rotating badge and its flame hand on
       mobile only... including the stray flame hand poking above the
       active card"): was repositioned into normal flow below the
       headline (see the removed rule's own comment); fully hidden now
       instead, per explicit request. */
    .situations-seal{ display: none; }
    .situations-cards-viewport{ order: 3; }
    /* own follow-up (process headline wrap): the base rule's
       max-width:52vw exists purely to keep clear of .situations-seal
       — moot here since the badge is hidden on this same breakpoint,
       immediately above. Left at 52vw it just cramped the headline
       for no reason; back to none so mobile gets the section's own
       full width to wrap in. */
    .situations-headline{ max-width: none; }
  }

  .situations-seal__ring{
    display: block;
    width: 100%;
    height: 100%;
  }
  /* own fix, per explicit report ("make this our font"): swapped off
     the generic monospace to this file's own type system — 'Aspekta
     Variable' ('DM Sans' as its own fallback — see the @font-face
     block near the top of this stylesheet) at weight 650, the same
     small-uppercase-label weight this file already uses elsewhere
     (e.g. .situation-card__footer-label). Letter-spacing halved from
     the mono value (0.12em -> 0.06em) for the same reason that swap
     halved its own: a monospace font's uniform character widths
     carry heavy tracking gracefully, but the same value on a
     proportional font like Aspekta reads as loose/uneven. */
  .situations-seal__ring text{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 8.6px;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    fill: color-mix(in srgb, var(--color-near-black) 65%, transparent);
  }
  .situations-seal__icon{
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: 84px;
    height: 84px;
    color: var(--color-near-black);
    pointer-events: none;
  }
  /* overrides the flame cursor's own fixed 40px sizing (see the
     FLAME CURSOR block near the top of this stylesheet) for this
     one static, much smaller reuse — same specificity trick the
     rest of this file uses (an extra ancestor class), no !important
     needed. */
  .situations-seal__icon .flame-cursor-inner,
  .situations-seal__icon .flame-cursor-inner svg{
    width: 84px;
    height: 84px;
  }
  @media (prefers-reduced-motion: no-preference){
    .situations-seal__ring{ animation: situationsSealSpin 18s linear infinite; }
  }
  @keyframes situationsSealSpin{ to{ transform: rotate(360deg); } }
  /* own fix, per explicit follow-up: the "01 / 08" counter that used
     to sit here (a small wayfinder label above the card row) has
     been removed entirely. */

  /* ---- card viewport — clips the track below to exactly the space
     left after the label/headline block above, full section width.
     The track inside is wider than this (all 8 cards laid out in a
     row) and gets translated horizontally by the script; this is
     just the window that width gets cropped to. ---- */
  .situations-cards-viewport{
    position: relative;
    flex: 1 1 auto;
    min-height: 0; /* lets it shrink below content size on very short viewports instead of forcing .situations past 100vh */
    overflow: hidden;
    border-top: 1px solid color-mix(in srgb, var(--color-near-black) 15%, transparent);
  }
  /* INTRO — own explicit request: the cards stay hidden through the
     full-screen open, fading in only once the headline/seal are
     mostly done shrinking (see setIntroProgress() in the SCROLL
     script, which is also what toggles pointer-events off directly
     on this element so a stray hover can't fire a card's headline
     swap while it's still invisible). Scoped under .is-pinned only —
     the mobile/reduced-motion fallback below (native horizontal
     scroll, no pin) never gets this custom property updated, so it
     must never affect that layout. */
  .situations.is-pinned .situations-cards-viewport{
    opacity: var(--situations-cards-opacity, 1);
  }
  .situations-cards{
    display: flex;
    height: 100%;
    will-change: transform;
  }
  /* ---- one card — full-height portrait column, big icon filling the
     upper area. flex-basis is vw-based (not %) so it sizes against
     the viewport rather than the track's own auto width, which would
     be circular; the numbers land on ~3-4 visible cards across the
     whole motion-enabled range (see MOTION_MIN_WIDTH in the script).

     Hover: the card's own background swaps to a contrasting color
     behind the icon, and a rounded pill — name, description, circular
     arrow button — slides up from below the card's bottom edge.
     overflow:hidden on the card is what clips the pill to invisible
     at its resting translateY(150%). Gated as ONE block behind real
     hover + fine pointer + no-reduced-motion (not just the
     transitions) so touch and reduced-motion users get neither the
     fill nor the pill at all, per this task's own explicit rule —
     stricter than this section's very first hover pass, which only
     gated the transition and still let reduced-motion users see an
     instant color jump. ---- */
  .situation-card{
    position: relative;
    overflow: hidden; /* clips the pill's off-screen resting position */
    /* Own fix, per explicit request: 4 cards visible per row again,
       not 5. .situations-sticky's own 6vw side padding leaves 88vw of
       viewport width for the card row, so 22vw per card (88/22=4)
       keeps exactly ~4 in view at any width the fluid middle value is
       actually driving — the old 26vw undershot that (88/26≈3.4) at
       typical widths, and its 400px cap didn't scale with the
       viewport at all, so wide/large screens kept fitting a 5th card
       in as the available width grew past it. Cap raised in step,
       widening the range this stays viewport-proportional across
       before an ultra-wide monitor could tip it to 5 again. ---- */
    flex: 0 0 clamp(260px, 22vw, 460px);
    display: flex;
    flex-direction: column;
    align-items: stretch; /* icon-zone needs to bleed full card width for its hover fill to read as "the card's background behind the icon" */
    text-align: left;
    padding: 0; /* moved to .situation-card__icon-zone / __footer individually, below */
    background: var(--color-cream);
    border: none;
    border-right: 1px solid color-mix(in srgb, var(--color-near-black) 15%, transparent);
    font: inherit;
    color: var(--color-near-black); /* icon/text color — stays constant; it already contrasts fine on both cream and the icon-zone's orange, so it never needs to flip */
    text-decoration: none; /* own follow-up: card is a real <a> now (see BUILD in script), same reset every other full-card link on this page uses */
    cursor: pointer;
    -webkit-appearance: none;
    appearance: none;
  }
  .situation-card:focus-visible{ outline: 2px solid var(--color-near-black); outline-offset: -2px; } /* a plain focus ring isn't "animation" or "fill" — kept even under reduced motion, unlike the block below */
  /* ---- icon zone — own choice: a full-width flex region wrapping
     just the icon, separate from the footer below, so the hover fill
     ("Instead of filling the whole card... behind the icon") only
     colors this upper area and never touches the footer/pill's own
     ground. ---- */
  .situation-card__icon-zone{
    flex: 1 1 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 40px 28px 24px;
  }
  .situation-card__icon{
    width: clamp(128px, 18vw, 256px);
    height: clamp(128px, 18vw, 256px);
    object-fit: contain;
    display: block;
  }
  /* ---- footer — always-visible name + short description (own
     choice: kept from this section's original spec so touch and
     reduced-motion users, who never get the hover-only pill below,
     still have something to read). Fades out on hover as the pill
     slides up to occupy roughly the same area. ---- */
  .situation-card__footer{
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 0 28px 32px;
  }
  /* own fix, per explicit report ("generic monospace that looks
     default/templated"): swapped to this file's own type system —
     'Aspekta Variable' (the site's one loaded display/body typeface,
     'DM Sans' as its own fallback — see the @font-face block near
     the top of this stylesheet) at weight 650, the same small-
     uppercase-label weight already established elsewhere in this
     file (e.g. .services-cta). Letter-spacing
     halved from the mono values (0.06em/0.04em -> 0.03em/0.02em):
     a monospace font's uniform character widths carry heavy tracking
     gracefully, but the same values on a proportional font like
     Aspekta read as loose/uneven rather than crisp. */
  .situation-card__footer-label{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 13px;
    text-transform: uppercase;
    letter-spacing: 0.03em;
  }
  .situation-card__footer-desc{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.02em;
    opacity: 0.6;
  }
  /* ---- pill — hover-only, richer echo of the footer above (name +
     desc again, plus the circular arrow): a rounded pill slides up
     from below the card's bottom edge. overflow:hidden on the card
     is what clips it to invisible at its resting translateY(150%). ---- */
  .situation-card__pill{
    position: absolute;
    left: 20px;
    right: 20px;
    bottom: 20px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    padding: 16px 16px 16px 28px;
    border-radius: 999px;
    background: var(--color-cream); /* CONFIRMED: the pill itself sits on a light ground regardless of the icon-zone's own hover background */
    color: var(--color-near-black);
    transform: translateY(150%);
    opacity: 0;
    pointer-events: none;
  }
  .situation-card__pill-text{
    display: flex;
    flex-direction: column;
    min-width: 0;
  }
  /* own fix, per explicit report ("kill the grey small text... main
     font tiny... even the buttons can be bigger"): dropped the desc
     line entirely, bumped the label up to carry the pill on its own,
     and scaled the pill/arrow to match. */
  .situation-card__label{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 20px;
    letter-spacing: 0.02em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .situation-card__desc{
    display: none;
  }
  .situation-card__arrow{
    flex-shrink: 0;
    width: 52px;
    height: 52px;
    border-radius: 50%;
    border: 1px solid color-mix(in srgb, var(--color-near-black) 25%, transparent);
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .situation-card__arrow svg{ width: 22px; height: 22px; }
  /* ---- gated as ONE block behind real hover + fine pointer + no-
     reduced-motion (not just the transitions) so touch and reduced-
     motion users get neither the fill nor the pill at all, per this
     task's own explicit rule. ---- */
  @media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference){
    .situation-card{ transition: color .3s ease-in-out; }
    .situation-card__icon-zone{ transition: background-color .3s ease-in-out; }
    .situation-card__footer{ transition: opacity .25s ease; }
    .situation-card__pill{ transition: transform .4s cubic-bezier(.25,.1,.25,1), opacity .3s ease; }
    /* violet instead of the brand orange — orange behind the icon read
       as a same-color clash against the icons' own orange accent
       strokes, and this section already leans on orange elsewhere.
       Card text (the fading footer) switches to cream so it stays
       legible on violet. own fix, per explicit follow-up (new style-C
       icon set): the icon itself no longer gets a recolor filter here
       — that used to flatten the old single-tone black linework to
       cream via brightness(0)/invert(1)/brightness(0.915), but this
       set's own hover GIF (see /hero-v2/assets/icons and
       setActiveCard() in the SCROLL script) already bakes its cream
       linework in directly, alongside an orange fill that filter
       would have flattened to white too. No CSS recolor needed: the
       PNG/GIF swap alone carries both the line-color change and the
       kept-orange fill. The pill is untouched either way — it sets
       its own color directly, not inherited from .situation-card.
       Own fix, per explicit request: the fill used to land only on
       .situation-card__icon-zone (the upper flex region), which
       stopped short of the footer area the pill sits in — so the
       pill's own bottom portion sat against the card's plain resting
       cream, the same color as the pill itself, and effectively
       vanished there (read as "cut off"/"invisible", not actually
       clipped). Moved to the card as a whole: icon-zone and footer
       have no background of their own, so violet now shows through
       both and the pill reads as a cream pill on a violet card top to
       bottom, with violet visible below and around it the whole time
       it's in view.
       Own fix, per explicit follow-up: the fill/icon-invert/footer-
       fade/pill-reveal used to be painted directly by :hover and
       :focus-visible here — a SEPARATE visual authority from the
       .is-current class below (driven by setActiveCard(), scroll AND
       mouse alike). Two independent sources meant two cards really
       could end up painted "active" at once — a real hover sustained
       on one card by a motionless pointer, plus .is-current landing
       on a different card from the scroll-driven advance — a bug a
       screenshot confirmed. Removed entirely: :hover/:focus-visible
       paint nothing of their own now, and the mouseenter/mouseleave/
       focus/blur listeners below call setActiveCard() the same as
       scroll does, so .is-current is the ONLY thing that ever paints
       this fill — one card, always, never two. ---- */
  }

  /* ---- .is-current — own fix, per explicit follow-up: the ONLY
     source of the card-active fill/footer-fade/pill-reveal (see the
     removal note above) — every interaction that can make a card
     "active," scroll (see setActiveCard() in the SCROLL script below)
     and real mouse/keyboard hover alike, goes through the same
     setActiveCard() call, which always resets every OTHER card's
     class in the same pass. That single shared gate is what
     guarantees at most one card is ever current, regardless of which
     interaction triggered it last. NOT gated to the transition
     block's hover-capable media query above: motionEnabled (width +
     reduced-motion only, see the script) is what gates whether this
     class ever gets toggled at all, so a wide touch-only device still
     gets the instant (untransitioned) end state instead of nothing.
     own fix, per explicit follow-up (new style-C icon set): no longer
     also swaps a .situation-card__icon filter here — see the media
     query above for why that's gone. ---- */
  .situation-card.is-current{ background: var(--color-violet); color: var(--color-cream); }
  .situation-card.is-current .situation-card__footer{ opacity: 0; }
  .situation-card.is-current .situation-card__pill{
    transform: translateY(0);
    opacity: 1;
    pointer-events: auto;
  }

  /* ---- fallback — reduced motion, or narrower than
     MOTION_MIN_WIDTH: the script never adds .is-pinned or the extra
     scroll height, so .situations stays a plain 100vh block and this
     becomes a native horizontally-scrollable, snap-to-card strip
     instead of a scroll-pinned one — every card stays reachable
     (swipe/trackpad/arrow-scroll) with no JS driving it and no
     vertical scroll-hijacking. ---- */
  .situations:not(.is-pinned) .situations-cards-viewport{
    overflow-x: auto;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
  }
  .situations:not(.is-pinned) .situation-card{ scroll-snap-align: start; }

  /* unconditional base — belt-and-suspenders alongside the two
     media queries below (one turns this on, the other explicitly
     turns it back off) so there's no ambiguous default if neither
     happens to match. */
  .process-intro{ display: none; }
  /* ============================================================
     PROCESS INTRO — mobile-only, explicit rebuild (own fix, per
     real-device bug report: the previous mobile intro pinned/overlaid
     .situations-copy on top of the cards via position:fixed/absolute
     toggled from JS — confirmed live, this could visually desync from
     its own DOM position under fast scroll and render the headline
     AFTER the cards instead of before them). #processIntro (markup:
     see its own comment, right before <section id="situations">) is
     now a genuinely separate, PRECEDING sibling — DOM order fixes the
     ordering bug structurally, not a pin.
     position:sticky (native, not a JS-toggled position) is what holds
     it at the top of the viewport for its own reserved scroll range —
     the same technique #heroPinWrapper already uses, for the same
     reason: sticky is resolved by the browser's own layout/paint
     pipeline every frame, synchronously, so it cannot lag behind or
     desync from a fast scroll the way an async JS position-toggle can.
     Combined with (prefers-reduced-motion: no-preference) so reduced-
     motion users skip this whole mechanism entirely and just get
     .situations-copy's own plain, static, un-shrunk default headline
     instead (the .situations-copy override right below is the other
     half of that same handoff) — matching how reduced motion already
     short-circuits the desktop pin elsewhere in this file. */
  @media (max-width: 767px) and (prefers-reduced-motion: no-preference){
    .process-intro{
      display: block;
      position: relative;
      /* JS (see the script further down) sets the real value — 100svh
         plus however much extra scroll distance the shrink/type-on
         ease gets to play out across — the instant it can measure
         window.innerHeight. This fallback only covers the brief gap
         before that first measurement lands, so there's no zero-
         height flash. */
      min-height: 160svh;
    }
    .process-intro-sticky{
      position: sticky;
      top: 0;
      height: 100svh;
      display: flex;
      align-items: center;
      padding: 0 20px;
    }
    /* the dedicated block above owns the fullscreen-intro moment on
       mobile now — hide this same text where it'd otherwise also sit,
       still in its usual compact spot right above the cards, so it
       doesn't render (or get announced to a screen reader) twice.
       Scoped to .situations-copy specifically, not a bare
       .situations-headline.is-typewriter rule, since #processIntro's
       own headline reuses these exact same classes for its own
       styling and must stay visible. Per-card headlines
       (setHeadlineLetters(), never carries .is-typewriter) are
       unaffected either way. */
    .situations-copy .situations-headline.is-typewriter{ display: none; }
  }
  @media (min-width: 768px), (prefers-reduced-motion: reduce){
    .process-intro{ display: none; }
  }

  @media (max-width: 680px){
    .situation-card{ flex-basis: 82vw; }
    .situation-card__icon-zone{ padding: 32px 24px 20px; }
    .situation-card__footer{ padding: 0 24px 28px; }
  }

  /* own fix, per real-device report: below MOTION_MIN_WIDTH the pin
     script never runs (see .situations's own comment), so .situations
     and .situations-sticky stay a plain height:100vh box with
     .situation-card__icon-zone's flex:1 1 auto (base rule, way above)
     stretching to fill essentially all of that height — the icon
     visibly floats in a ~100vh-tall card with the footer pinned to
     the very bottom, and the horizontal-scroll fallback's own
     border-right on each card (the "vertical divider") reads as a
     stray line/tick mark once cards are this tall. Rebuilt as a
     plain full-width vertical stack instead of the horizontal 82vw
     swipe row above: every ancestor that was sized to a fixed
     viewport height (for the pin/scroll mechanics that don't apply
     here anyway) switches to auto so the section can size to its own
     stacked content, the row flips to a column, and each card becomes
     a compact fixed-size row — small icon, then title/subtitle
     directly under it — with a bottom divider instead of the old
     side-by-side right border. */
  /* own rebuild, per explicit request: a swipeable single-card
     carousel replacing the previous 2-up static grid (which was
     itself matched to the supplied mockup — the mockup's own caption
     under it, "All situations in a 2-up grid...", was a build note
     for that earlier pass, not real site copy, and doesn't apply
     here regardless). One card at ~84vw with the next one peeking,
     scroll-snap, 20px left gutter (matched by an equal scroll-padding
     on the viewport so every subsequent card also snaps flush with
     it, not just the first), 12px gap. Natural height + flex's own
     default align-items:stretch is what makes every card match the
     tallest one without measuring anything — the same technique this
     file already leans on elsewhere for that. */
  @media (max-width: 767px){
    .situations{ height: auto; }
    .situations-sticky{ height: auto; padding: 6vh 0; }
    .situations-copy{ padding: 0 20px; } /* own addition: replaces the side padding moved off .situations-sticky above, now that the card row needs to bleed to the edge for its own peek/gutter below */
    /* own fix, explicit rebuild: the section's own default (-1)
       headline used to also carry the fullscreen-intro moment on
       mobile (via the now-removed MOBILE INTRO PIN, .is-pinned on
       .situations). #processIntro above owns that role now — hiding
       this copy of the SAME text here (still shown normally on
       desktop and on the 768-1023px tablet range, where no intro of
       either kind ever runs) avoids announcing/rendering it twice.
       Scoped to .situations-copy specifically (not a bare
       .situations-headline.is-typewriter rule) since #processIntro's
       own headline reuses these exact same classes for its styling
       and must stay visible — see its own markup comment. Per-card
       headlines (setHeadlineLetters(), never carries .is-typewriter)
       are unaffected either way — see the combined media query further
       below (own reasons there for why this isn't declared inline
       here alongside the rest of this block). */
    .situations-cards-viewport{
      flex: none;
      overflow: visible;
      border-top: none;
    }
    .situations:not(.is-pinned) .situations-cards-viewport{
      overflow-x: auto;
      overflow-y: visible;
      scroll-snap-type: x mandatory;
      scroll-padding-left: 20px;
      -webkit-overflow-scrolling: touch;
    }
    .situations-cards{
      display: flex;
      align-items: stretch;
      height: auto;
      gap: 12px;
      padding: 0 20px;
    }
    .situation-card{
      flex: 0 0 84vw;
      width: auto;
      height: auto;
      border-right: none;
      border-radius: 16px;
      scroll-snap-align: start;
      /* own follow-up ("inactive cards need a visible card background
         so they read as cards"): resting cream matched the section's
         own background too closely on a real device — a lighter,
         distinct surface + a hairline border give the card its own
         edge regardless of what's behind it. */
      background: color-mix(in srgb, var(--color-cream) 40%, white);
      border: 1px solid color-mix(in srgb, var(--color-near-black) 12%, transparent);
    }
    .situation-card.is-current{ border-color: transparent; } /* the violet fill (base rule) already reads as a card on its own */
    .situation-card__icon-zone{
      flex: 0 0 auto;
      padding: 28px 24px 12px;
      justify-content: center; /* own follow-up ("center the GIF horizontally") — was flex-start */
    }
    .situation-card__icon{ width: 180px; height: 180px; } /* was 160px, per explicit request */
    .situation-card__footer{ padding: 0 24px 28px; }
    /* per explicit request: same case as desktop's hover-pill copy
       (SITUATIONS_CONFIG.label/desc are already sentence case, e.g.
       "Demand", "Demand is stalling") — the always-visible footer
       shown here uppercases that same text in its own base rule
       (shared with desktop, where it's the RESTING state under the
       hover-only pill), which read fine there but wasn't what this
       spec wanted for mobile's own primary, always-visible text. */
    .situation-card__footer-label,
    .situation-card__footer-desc{ text-transform: none; }
    /* own follow-up ("card text is too small"): title/subtitle bumped
       up from the desktop-shared 13px/10px resting-footer sizes. */
    .situation-card__footer-label{ font-size: 24px; }
    .situation-card__footer-desc{ font-size: 16px; opacity: 0.7; }
    /* the hover-only pill (desktop) never gets shown on a touch
       device (its own trigger is real :hover) — hidden outright here
       so it can't ever occupy layout space in the carousel. */
    .situation-card__pill{ display: none; }
    /* own fix: the shared .is-current rule (base CSS, above) fades
       the footer out on desktop because the hover pill (name+desc
       again) slides up to replace it — with the pill hidden above,
       that same fade left the active/violet mobile card showing
       nothing but its icon, no title or subtitle at all. Kept visible
       here instead, since the footer IS this layout's only copy. */
    .situation-card.is-current .situation-card__footer{ opacity: 1; }
    /* per explicit request: "if the desktop card has one, its short
       description" — the pill's own richer copy (SITUATIONS_CONFIG's
       full label+desc) already lives here in .situation-card__footer,
       shown on both resting and active states now, so no further
       change is needed to surface it. */
  }
  /* own addition: "1 / 9" progress counter under the carousel, per
     explicit request ("small dots or a counter... tapping optional" —
     a plain counter needs no extra tap targets). Desktop never shows
     it: the pinned/hover-driven layout has no notion of a single
     "current" card until one's actually hovered, and this section's
     own base rules never touch it, so display:none is the correct
     default rather than something only the mobile query sets. */
  .situations-counter{ display: none; }
  @media (max-width: 767px){
    .situations-counter{
      display: block;
      order: 4;
      margin: 12px 0 0;
      text-align: center;
      font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
      font-weight: 500;
      font-size: 13px;
      color: color-mix(in srgb, var(--color-near-black) 55%, transparent);
    }
  }

  /* ============================================================
     CRAFTED / "PASSIONATE ODDBALLS" — v2 rebuild, own build of a
     repeated-headline text-echo effect. Every tunable lives in
     ECHO_CONFIG below.

     ONE line ("PASSIONATE ODDBALLS"). Copies 2-6 crop the
     letters from the TOP, keeping the baseline intact: each copy is
     a fixed-height, overflow:hidden box with its full-height text
     bottom-aligned inside it, so only the bottom slice shows. A small
     fixed gap (4px/~2.2% of copy 1's own height, from
     .crafted-text's own margin-bottom) sits between every row.

     FONT — DM Sans at weight 500 (Medium): a regular/medium-weight
     grotesque, not the heavy/expanded face this file uses for other
     headlines (Archivo Expanded 900 would be far too bold/wide a
     match here) — DM Sans is this file's own grotesque body/UI face
     and 500 is already loaded.

     COLOR — data-theme="light" so the cursor trail switches to its
     dark color here, same mechanism as every other data-theme switch
     in this file — and the same switch is what makes the corner
     doodle below auto-load its -dark line variant. Colors live in
     ECHO_CONFIG.colors and are applied as CSS custom properties.
  ============================================================ */
  .crafted{
    position: relative;
    background: var(--crafted-bg, var(--color-orange));
    color: var(--crafted-text, var(--color-near-black));
    padding: 18vh 0; /* zero side padding/margin, full-bleed edge-to-edge — vertical padding is our own choice, a deliberate bold pause between sections */
    overflow: hidden; /* clips each echo copy's off-screen translateY starting position */
  }
  .crafted-stack{
    display: flex;
    flex-direction: column;
    width: 100%;
  }
  /* own addition: wraps the single .crafted-stack — a no-op plain
     block on desktop, and the unit the mobile media query below adds
     side padding to. */
  .crafted-echo-group{
    width: 100%;
  }
  .crafted-copy{
    margin: 0;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    text-transform: uppercase;
    line-height: 1;
    white-space: nowrap;
    /* fluid fallback before the script measures+fits the real width
       (see fitText) — script overwrites --crafted-fs once layout/fonts settle. */
    font-size: var(--crafted-fs, clamp(32px, 13vw, 220px));
  }
  /* echo copies 2-6: each is a shorter box (script sets
     --crafted-copy-h from copy 1's own measured height x
     ECHO_CONFIG.cropFractions), overflow:hidden + flex align-items:
     flex-end so the full-height text inside sits bottom-aligned —
     only its bottom slice is visible, top cropped away, baseline
     intact. A small margin-bottom (ECHO_CONFIG.gapFraction of copy
     1's height) sets a ~4px/~2.2% row gap. Each starts translated up
     (own %, from ECHO_CONFIG.startY) and slides down to
     translateY(0) once the section first scrolls into view;
     duration/delay/easing are set inline by the script from
     ECHO_CONFIG so that object stays the single source of truth. */
  .crafted-copy:not(.crafted-copy--1){
    overflow: hidden;
    height: var(--crafted-copy-h, auto);
    margin-bottom: var(--crafted-gap, 0);
    display: flex;
    align-items: flex-end;
    transform: translateY(var(--crafted-start-y, -100%));
    transition-property: transform;
  }
  .crafted-copy__inner{ display: inline; } /* default inline — so getBoundingClientRect() on it measures the text's own glyph width, not the parent h2's full box width */
  .crafted.is-played .crafted-copy:not(.crafted-copy--1){ transform: translateY(0); }

  /* ---- per-letter hover flip — own build: not a true 3D rotateY,
     just transform:scaleX(-1) on each letter around its own center,
     0.75s cubic-bezier(.76,0,.24,1) — the smooth pass through zero
     width during the transition is what reads as a "flip". Applied
     to every copy, cropped ones included: since each crafted-copy
     clones the SAME letter-span markup (built once below, before
     the clone loop runs) and copies 2-6 are just a shorter
     overflow:hidden window onto that same full-height text, a
     letter flipping in place is naturally clipped to whatever slice
     of it that copy already shows — no special-casing needed. Gated
     to real hover + fine pointer + no reduced-motion in one media
     query rather than a separate reduced-motion override rule, so
     touch and reduced-motion users never get a transition to
     disable in the first place. ---- */
  .crafted-letter{
    display: inline-block;
    transform-origin: center;
  }
  /* Own fix: this used to be a pure CSS :hover transition, which let a
     mouse leaving mid-flip simply reverse from whatever scaleX it
     happened to be at — the reported stuck, half-turned, washed-out
     letter. Now .is-flipping (added/removed by the LETTER FLIP script
     below, never by :hover) plays this keyframe animation once,
     start to finish, with no way to interrupt it mid-transform: flip
     out over the first 30%, hold flipped through the middle 40% (~1s
     of the 2.5s total), flip back over the last 30%. Same easing as
     the old transition, just now a single uninterruptible sequence
     instead of a value CSS can reverse out from under itself. */
  @media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference){
    .crafted-letter.is-flipping{
      animation: craftedLetterFlip 2.5s cubic-bezier(.76,0,.24,1);
    }
    @keyframes craftedLetterFlip{
      0%   { transform: scaleX(1); }
      30%  { transform: scaleX(-1); }
      70%  { transform: scaleX(-1); }
      100% { transform: scaleX(1); }
    }
  }


  /* ---- prefers-reduced-motion: final resting state immediately, no
     slide/transition at all (our own addition). ---- */
  @media (prefers-reduced-motion: reduce){
    .crafted-copy:not(.crafted-copy--1){ transform: translateY(0); transition: none; }
  }

  /* own rebuild, per explicit bug report ("oddballs type too small on
     mobile"): back to the alternating one-word-per-row stack (an
     earlier pass tried this once already — see the git history this
     comment used to sit next to — reverted at the time for a
     different, since-fixed reason: wrapping ONE shared component to
     two lines broke the crop-from-top echo technique, not the
     multi-row idea itself). This time it's genuinely N independent
     .crafted-echo-group instances — the exact same unmodified
     buildEchoGroup()/fit() desktop already uses, called once per row
     instead of once total (see the MOBILE branch in the script) — so
     each row keeps its own full crop/slide/hover-flip mechanics with
     nothing shared or wrapped between rows. Tight top/bottom padding
     (down from desktop's own 18vh) since the row stack itself is what
     should fill the section now, not padding. */
  @media (max-width: 767px){
    .crafted{ padding: 24px 0; }
    .crafted-echo-group{ padding: 0 20px; }
    .crafted-mobile-rows{
      display: flex;
      flex-direction: column;
      /* own choice: a small breathing gap between alternating words —
         proportionally similar to ECHO_CONFIG.gapFraction's own
         intra-group crop gap, just applied between rows instead of
         between crop layers within one row. */
      gap: 1.5vh;
    }
  }

  /* ============================================================
     TEAM SLIDER — own build of a drifting, full-bleed photo row.
     Photos are our own team/office shots (see /hero-v2/assets/team).
     Every tunable lives in SLIDER_CONFIG in the script below.

     BACKGROUND — light #E9E6DF (overnight revision — was near-black;
     see OVERNIGHT-NOTES.md Part 1), data-theme="light" on the section
     in the HTML so the pixel-trail cursor switches to its dark color
     here, same mechanism every other data-theme switch in this file
     uses. The direction cursor stays flame-orange regardless (its own
     fixed color, not tied to data-theme).

     LAYOUT — full-bleed (no side padding), photos edge-to-edge with
     zero gap, 10px corner radius, portrait-ish ~0.88 width/height
     ratio.

     data-flame-cursor="off" / data-trail="off" removed from this
     section per direct feedback (flame cursor + trail visible
     everywhere, no exceptions) — the custom direction-cursor below
     now shows alongside the flame cursor rather than replacing it.
     Precedent this should be fine: Selected Works' own .works-cursor
     (z-index 55, below the flame cursor's 60) already coexists with
     the flame cursor with no suppression anywhere in that section.
  ============================================================ */
  .team-slider{
    position: relative;
    background: var(--team-slider-bg, var(--color-cream));
    padding: 8vh 0;
    overflow: hidden;
  }
  .team-slider:focus-visible{ outline: 2px solid var(--color-orange); outline-offset: -2px; }
  .team-slider-viewport{
    position: relative;
    overflow: hidden;
    width: 100%;
    touch-action: pan-y; /* lets vertical page-scroll pass through on touch while horizontal drag is handled by our own pointer code */
  }
  .team-slider-track{
    display: flex;
    width: max-content;
    gap: var(--team-slide-gap, 12px); /* own choice, a small breathing gap between photos */
    will-change: transform;
  }
  .team-slide{
    flex: none;
    height: var(--team-slide-h, 46vh);
    max-height: 480px;
    min-height: 240px;
    aspect-ratio: var(--team-slide-aspect, 0.877);
  }
  .team-slide img{
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: 10px;
    pointer-events: none; /* dragging/clicking is handled by the track/viewport, not individual images */
    user-select: none;
    -webkit-user-drag: none;
  }

  /* ---- direction cursor: a flame-orange circular badge with a left
     or right arrow, following the pointer with zero added lag (same
     position: fixed + raw-transform convention as .flame-cursor
     above), swapped between left/right arrow
     depending which half of the slider it's currently over. Hidden
     entirely on touch/no-fine-pointer (canHover gates its init in
     the script), and the system/flame cursor stays hidden over the
     slider via plain CSS cursor:none while it's active. ---- */
  .team-slider-cursor{
    position: fixed;
    top: 0;
    left: 0;
    z-index: var(--z-team-slider-cursor); /* below the flame cursor (moot here since the flame cursor is inactive in this section, but keeps the stacking order consistent) */
    width: var(--tsc-size, 68px);
    height: var(--tsc-size, 68px);
    margin: calc(var(--tsc-size, 68px) / -2) 0 0 calc(var(--tsc-size, 68px) / -2);
    border-radius: 50%;
    background: var(--color-orange);
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
    opacity: 0;
    transform: scale(0.6);
    transition: opacity .2s ease, transform .2s cubic-bezier(.215,.61,.355,1);
  }
  .team-slider-cursor.is-active{ opacity: 1; transform: scale(1); }
  .team-slider-cursor svg{ width: 38%; height: 38%; display: block; }
  .team-slider.has-cursor, .team-slider.has-cursor .team-slide{ cursor: none; }

  .team-slider-a11y-btn{
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0,0,0,0);
    white-space: nowrap;
    border: 0;
  }

  @media (max-width: 680px){
    .team-slide{ height: 34vh; min-height: 180px; }
  }

  /* own addition, matching the supplied mobile-design spec
     (05-oddballs-stack-photos.png): on mobile the prev/next buttons
     (already fully functional — see step()/prevBtn/nextBtn in the
     script, previously just visually hidden for a11y-only keyboard/
     screen-reader use) become real visible circular orange controls,
     bottom-right, with a "Swipe" label at bottom-left. */
  @media (max-width: 767px){
    .team-slider{ position: relative; padding-bottom: 64px; }
    /* own follow-up ("starts flush against the left edge — same 20px
       inset as Selected works"): this slider isn't a native scroll
       container (no scrollLeft/scroll-snap — see the script's own
       POSITION block, a custom translateX loop), so scroll-padding-
       left has nothing to attach to here. A plain left padding on the
       viewport does the same visual job: every getBoundingClientRect()
       measurement the script makes is a real screen coordinate, so it
       picks up this offset automatically with no script change. */
    .team-slider-viewport{ padding-left: 20px; }
    .team-slider-a11y-btn{
      position: absolute;
      width: 44px;
      height: 44px;
      margin: 0;
      padding: 0;
      clip: auto;
      overflow: visible;
      white-space: normal;
      bottom: 12px;
      border: none;
      border-radius: 999px;
      background: var(--color-orange);
      color: var(--color-near-black);
      font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
      font-weight: 700;
      font-size: 0; /* hides the real "Previous/Next photo" a11y text visually; the arrow glyph below is its own ::after, not text */
      cursor: pointer;
    }
    .team-slider-a11y-btn::after{
      content: "";
      position: absolute;
      top: 50%; left: 50%;
      width: 8px;
      height: 8px;
      border-right: 2px solid currentColor;
      border-bottom: 2px solid currentColor;
    }
    #teamSliderPrev{ right: 64px; }
    #teamSliderPrev::after{ transform: translate(-30%, -50%) rotate(135deg); }
    #teamSliderNext{ right: 12px; }
    #teamSliderNext::after{ transform: translate(-70%, -50%) rotate(-45deg); }
    /* own fix, explicit feedback ("we don't need to say 'swipe' on
       mobile — the buttons signal that"): removed the ::before "Swipe"
       label entirely — the two circular prev/next buttons already
       communicate this row is navigable on their own. */
  }

  /* ============================================================
     FOOTER v2 — own build, structure modeled on a reference product-
     page footer (confirmed live: a small logo mark top-left beside a grid of
     titled link columns, each with a thin rule under its title and a
     thin rule down its left edge, a full-width divider, then a
     dominant display-text line lower in the footer, and a slim
     credits row at the very bottom). Own colors, own copy, own "To
     The Moon" line, own columns/content throughout — matched
     structure only, nothing copied. Replaces the earlier "EBD"
     wordmark footer (see git history). Violet background + white
     text/lines (data-theme="dark" so the cursor trail keeps its
     light/bright default color here, same mechanism every other
     data-theme switch in this file uses — this background reads
     dark enough to want that). Columns, moon line, and bottom row
     all built by script from FOOTER_CONFIG, same convention as every
     other generated section.
  ============================================================ */
  .site-footer{
    position: relative;
    background: var(--footer-bg, var(--color-violet));
    padding: 8vh 4vw 5vh;
    overflow: hidden; /* clips the moon line's pre-reveal translateY offset */
  }
  /* subtle paper-grain overlay — a tiny tiled SVG feTurbulence noise
     tile, painted behind every other footer child (z-index) at low
     opacity so it never touches text sharpness (it's a background
     layer, not a filter/blur on the content itself). */
  .site-footer-grain{
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    opacity: .05;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
    background-repeat: repeat;
    background-size: 120px 120px;
  }

  .site-footer-top{
    position: relative;
    z-index: 1;
    display: flex;
    align-items: flex-start;
    gap: 4vw;
  }
  /* logo-mark slot — the doodle + its "Made in Boulder" caption,
     stacked and centered as one unit so the caption always stays
     aligned under the drawing at any breakpoint (flex-column on the
     wrapper, not on the doodle itself) — same static-position
     override every other inline doodle placement in this file uses;
     sized bigger than a typical small logo mark so the skier itself
     stays readable, per this task's own instruction. */
  .site-footer-logo-slot{
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    flex: none;
  }
  /* ~2x the original size so the skier + lava read clearly, per
     explicit feedback; shrinks a step on tablet so it doesn't crowd
     the 4 columns, and drops to a comfortable in-between size on
     mobile where it stacks above the columns instead of beside them
     (see .site-footer-top's own max-width:560px rule). Own fix, per
     explicit follow-up ("still not very legible"): another ~30% on
     top of that at every step (192->250, 140->182, 112->146). */
  #doodleFooterFlatironsSki{ position: static; width: 250px; }
  @media (max-width: 900px){
    #doodleFooterFlatironsSki{ width: 182px; }
  }
  @media (max-width: 560px){
    #doodleFooterFlatironsSki{ width: 146px; }
  }
  /* matches the column titles exactly (font/size/weight/tracking/
     uppercase/white) per this task's own spec — no rule line under
     it, that's specific to the column titles. */
  .site-footer-logo-caption{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 13px;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--color-cream);
  }
  .site-footer-columns{
    flex: 1;
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 24px;
  }
  .site-footer-col{
    border-left: 1px solid var(--color-cream);
    padding-left: 20px;
  }
  .site-footer-col-title{
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 650;
    font-size: 13px;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--color-cream);
    margin: 0 0 16px;
    padding-bottom: 12px;
    border-bottom: 1px solid var(--color-cream);
  }
  .site-footer-col-list{
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .site-footer-col-list li{ display: flex; align-items: center; gap: 6px; }
  .site-footer-col-list a, .site-footer-col-list span{
    display: inline-block;
    color: var(--color-cream);
    text-decoration: none;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 15px;
    transition: transform .15s ease-out;
  }
  /* subtle hover — text nudges right + underlines, per this task's
     own spec, in place of the old footer's color-swap hover. */
  .site-footer-col-list a:hover{
    transform: translateX(4px);
    text-decoration: underline;
    text-underline-offset: 3px;
  }
  .site-footer-divider{
    position: relative;
    z-index: 1;
    width: 100%;
    border-top: 1px solid var(--color-cream);
    margin: 5vh 0;
  }

  /* "To The Moon" — same treatment as the hero headline: real text
     (this wrap's child span) stays in the DOM for SEO/a11y, visually
     hidden via opacity:0, while a WebGL <canvas> on top renders the
     warped/hole-cut version through the shared window.HeadlineFX
     factory (see that block comment for why one factory, not a
     duplicate implementation). The wrap itself only carries layout +
     the reveal transform; typography lives on the text span so
     fitMoon() can measure its true shrink-to-fit width (the wrap's
     own width:100% would otherwise always report the full footer
     width, not the text's real content width — the original bug). */
  .site-footer-moon{
    position: relative;
    z-index: 1;
    width: 100%;
    transform: translateY(0.22em); /* pre-reveal offset — "rises up slightly into place" */
    transition-property: transform;
  }
  .site-footer.is-revealed .site-footer-moon{ transform: translateY(0); }
  @media (prefers-reduced-motion: reduce){
    .site-footer-moon{ transform: translateY(0) !important; transition: none !important; }
  }
  .site-footer-moon-text{
    display: inline-block; /* shrink-to-fit — see the block comment above */
    white-space: nowrap;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 800;
    letter-spacing: -0.01em;
    line-height: .85;
    color: var(--footer-text-color, var(--color-cream));
    font-size: var(--moon-fs, 14vw); /* fluid fallback before the script measures+fits the real width — see fitMoon in the script */
    opacity: 0;
  }
  .reduce-motion .site-footer-moon-text{ opacity: 1; }
  .site-footer-moon-canvas{
    position: absolute;
    inset: 0;
    z-index: 1;
    display: block;
    pointer-events: none;
  }
  .reduce-motion .site-footer-moon-canvas{ display: none; }
  /* rocket-mars-talking doodle — a normal (non-warped) image layered above
     the warp canvas, positioned in JS from the measured end of "Moon"
     (see positionRocketDoodle in the script): height/width/top/left
     are all set inline from there, not from CSS, since they depend on
     live text metrics. This rule only carries what doesn't change. */
  .site-footer-moon-doodle{
    position: absolute;
    z-index: 2;
    pointer-events: none;
  }

  .site-footer-bottom{
    position: relative;
    z-index: 1;
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-family: 'Aspekta Variable', 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 14px;
    color: var(--color-cream);
  }

  @media (max-width: 560px){
    .site-footer-top{ flex-direction: column; gap: 24px; }
    .site-footer-bottom{ flex-direction: column; align-items: flex-start; gap: 8px; }
  }
  /* own follow-up, replacing the earlier 2-up mobile layout per
     explicit request ("center everything so the GIFs can be larger"):
     single centered column, no left rules, a short centered underline
     under each remaining heading, ≥44px tap targets, doodles enlarged
     now that they aren't sharing width with side-by-side columns. */
  @media (max-width: 767px){
    .site-footer-top{ flex-direction: column; align-items: center; gap: 24px; text-align: center; }
    .site-footer-columns{ grid-template-columns: 1fr; width: 100%; text-align: center; }
    .site-footer-col{ border-left: none; padding-left: 0; }
    .site-footer-col-title{ border-bottom: none; padding-bottom: 14px; position: relative; }
    .site-footer-col-title::after{
      content: "";
      position: absolute;
      left: 50%; bottom: 0;
      transform: translateX(-50%);
      width: 32px; height: 1px;
      background: var(--color-cream);
    }
    .site-footer-col-list{ align-items: center; }
    .site-footer-col-list li{ justify-content: center; }
    .site-footer-col-list a, .site-footer-col-list span{ min-height: 44px; display: inline-flex; align-items: center; }
    #doodleFooterFlatironsSki{ width: 80vw; max-width: 310px; }
    /* moon/rocket: doodle moves into its own centered row above the
       text via flex order (DOM keeps text-then-doodle for the SEO/a11y
       reading order established elsewhere in this file) instead of
       fitMoon()'s desktop inline end-of-text placement — see the
       isMobileFooter branch in fitMoon() in the script, which clears
       the inline height/width/left/top it sets on desktop so these
       fixed values apply. */
    .site-footer-moon{ display: flex; flex-direction: column; align-items: center; gap: 24px; }
    /* the shared .doodle base rule assumes every doodle is a 600x600
       square (aspect-ratio:1/1) — true for every other doodle in the
       file, but rocket-mars-talking is 407x473 (see ROCKET_MARS_CONFIG
       in the script), so it needs its own real ratio here or height:
       auto renders it square. Own follow-up ("make it bigger, ~240px
       wide") — was 160px; the source asset itself is 407px wide (no
       @2x version exists in WEBFLOW_ASSET_MAP to swap in — checked),
       so at 240 CSS px it's still being displayed SMALLER than its
       native resolution on a standard screen, but a 3x-DPI phone
       (iPhone 13 included) needs 720 physical px for a fully sharp
       240px box and only has 407 to draw from, so it may read
       slightly soft there — flagged per explicit request, no sharper
       source available to fix it further. */
    .site-footer-moon-doodle{ order: -1; position: static; width: 240px; height: auto; aspect-ratio: 407 / 473; }
    .site-footer-moon-text{ font-size: var(--moon-fs, 13vw); }
    .site-footer{ padding-bottom: 72px; }
    .site-footer-bottom{ flex-direction: column; align-items: center; text-align: center; gap: 8px; }
  }

