/* ==========================================================================
   FANGIN — Mowermerch story: THE DREAM / THE SOLUTION / THE DIFFERENCE /
   THE RESULT.
   ------------------------------------------------------------------------
   Continues the case study directly after the locked "Selected Work"
   cover (.work-pin, work.css — untouched by anything in this file).

   Architecture note (why this differs from hero/transition/work):
   Those three sections use a GSAP `pin:true` ScrollTrigger, which needs a
   pin-spacer + imperative z-index handoff to avoid dead scroll gaps and
   stacking bugs between adjoining pins (see transition-scroll.js for the
   full explanation of that mechanism). Stacking three more pins back to
   back would compound that bookkeeping for no real benefit here, since
   these chapters don't need to hand off a still-animating background to
   one another the way transition->work does.

   Instead, every chapter below is a *tall* block (.story-chapter) with a
   `position: sticky` inner stage (.story-stage, height: 100vh). CSS keeps
   the stage glued to the viewport for free while its own scroll range
   plays out; a GSAP timeline scrubbed against
   `{start:'top top', end:'bottom bottom', scrub:1}` on the *chapter*
   maps 1:1 onto exactly that stuck window (chapter height − 100vh — the
   same distance a sticky element stays glued for), so content animates
   the whole time it's actually held on screen and never mid-slide-away.
   Chapters are plain in-flow siblings, so later ones simply cover earlier
   ones as they arrive — no z-index handoff needed between chapters.

   Structure (see index.html):
     .story
       .story-intro                    <- one-shot fade-in, not scrubbed
       .story-chapter.story-dream        .story-stage
       .story-chapter.story-solution      .story-stage
       .story-chapter.story-difference    .story-stage  (the IPL demo)
       .story-chapter.story-result        .story-stage
     .story-static-fallback            <- reduced-motion equivalent
   ========================================================================== */

.story {
  position: relative;
  background: var(--color-bone);
  isolation: isolate;
}

/* ---- Introduction ---- */
.story-intro {
  max-width: 760px;
  margin: 0 auto;
  padding: clamp(90px, 14vw, 160px) var(--edge-gutter) clamp(60px, 9vw, 110px);
}

.story-intro-eyebrow {
  color: var(--color-ink);
  opacity: 0.5;
  margin-bottom: clamp(16px, 2vw, 24px);
  opacity: 0;
  transform: translateY(10px);
  will-change: opacity, transform;
}

.story-intro-copy {
  font-family: var(--font-body);
  font-size: clamp(1.15rem, 1.9vw, 1.6rem);
  line-height: 1.5;
  color: var(--color-ink);
  opacity: 0;
  transform: translateY(14px);
  will-change: opacity, transform;
}

/* ---- Shared chapter scaffolding ---- */
.story-chapter {
  position: relative;
  width: 100%;
}

/* Tall enough that each keyword in the sequence (see initDream() in
   mowermerch-story.js) gets a genuinely long readable hold, not just a
   bigger fraction of a short distance — increased from 340vh during the
   polish pass specifically to slow that sequence down. */
.story-dream { height: 520vh; }
.story-solution { height: 360vh; }
.story-difference { height: 560vh; }
/* Reduced from 620vh (the old scroll-settle mood board's own pin range) —
   this chapter is now a drag-and-snap interaction (see initResult() in
   mowermerch-story.js), not a continuously scroll-scrubbed reveal, so its
   own scrub range only needs to carry the intro copy's entrance, a window
   for the scroll-driven auto-gather (a visitor who never touches a piece
   still sees the board gather itself as they scroll through this), and a
   short exit — not several sequential settle beats each needing their own
   long hold. A shorter range here also matters more than it did for the
   old board: with real drag interaction now live for however long this
   chapter stays pinned, an artificially long pin risks feeling like the
   scroll itself is "stuck" rather than like there's something to do. */
.story-result { height: 260vh; }
/* Homepage ending (see cfg.homepageEnding, config.js). Shorter than the
   case-study chapters above on purpose: the bridge is a deliberately
   quick editorial pause (two lines + a link, not a narrative beat), and
   the final CTA's own dedicated pin only needs to carry 4 reveal beats
   (see initFinalCta(), mowermerch-story.js), not a multi-part story. */
.story-bridge { height: 220vh; }
.story-final-cta { height: 420vh; }

.story-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  width: 100%;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Reserves the fixed header's own band (.site-header, header.css) so
     centered content can never land underneath it — header.css is
     untouched; this only keeps this chapter's own content clear of it. */
  padding: var(--header-height) var(--edge-gutter) 0;
  background: var(--color-bone);
}

.story-index {
  color: var(--color-ink);
  opacity: 0.5;
  margin-bottom: clamp(14px, 1.8vw, 22px);
}

.story-headline {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: clamp(3rem, 7vw, 7.5rem);
  line-height: 0.9;
  letter-spacing: -0.01em;
  color: var(--color-ink);
  margin-bottom: clamp(22px, 3.2vw, 44px);
}

.story-copy {
  display: flex;
  flex-direction: column;
  gap: clamp(12px, 1.6vw, 18px);
  max-width: 640px;
}

.story-copy p {
  font-family: var(--font-body);
  font-size: clamp(1rem, 1.3vw, 1.25rem);
  line-height: 1.55;
  color: var(--color-ink);
  opacity: 0;
  transform: translateY(16px);
  will-change: opacity, transform;
}

/* ---- 01 — The Dream: progressive keyword reveal ----
   Sits over the same area the copy occupies, not beside it — the copy
   recedes as these take over, one at a time, so this never reads as a
   feature-card grid. */
.story-keywords {
  position: relative;
  width: 100%;
  min-height: clamp(3.6rem, 9.4vw, 9.4rem);
  pointer-events: none;
}

.story-keyword {
  position: absolute;
  top: 0;
  left: 0;
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: clamp(3.4rem, 9vw, 9rem);
  line-height: 0.9;
  letter-spacing: -0.01em;
  color: var(--color-ink);
  opacity: 0;
  will-change: opacity, transform;
}

/* Dream sequence colour system: bright acid directly on cream is close to
   unreadable as text (~1:1 contrast — see tokens.css), so instead of
   darkening the colour itself, TRUSTED./EASY TO FIND. and AVAILABLE./
   AUSTRALIA-WIDE. each pair the full-brightness acid with a solid ink or
   acid block — both combinations read at full, easy contrast, and the
   block reads as part of the typography (a "highlighter" rather than a
   background panel) rather than a readability compromise. FAST. sits
   directly on cream in plain periwinkle, which already has comfortable
   contrast (2.5:1+) at this size/weight, so it needs no block.
   Sharp/square corners throughout — no radius, shadow, gradient, outline
   or texture, per the brief. Padding is in `em` so it scales with the
   responsive font-size and never grows disproportionately on narrow
   phones; the matching negative top/left margin cancels the padding's
   own offset so the actual glyph start position lines up with the plain
   (no-block) FAST. rather than jumping between words as the sequence
   plays — only the block itself extends around that fixed point. */
.story-keyword.kw-block-acid,
.story-keyword.kw-block-ink {
  padding: 0.06em 0.18em;
  margin: -0.06em 0 0 -0.18em;
  border-radius: 0;
}

.story-keyword.kw-block-acid {
  color: var(--color-ink);
  background: var(--color-acid);
}

.story-keyword.kw-block-ink {
  color: var(--color-acid);
  background: var(--color-ink);
}

.story-keyword.kw-periwinkle { color: var(--color-periwinkle); }

/* ---- 02 — The Solution: the search-to-purchase journey line ---- */
.story-journey {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: clamp(18px, 2.8vw, 34px);
  margin-top: clamp(28px, 3.6vw, 48px);
}

.story-journey-step {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: clamp(1.4rem, 2.8vw, 2.75rem);
  line-height: 1;
  color: var(--color-ink);
  opacity: 0;
  will-change: opacity, transform;
}

.story-journey-step:nth-of-type(3n+2) { color: var(--color-periwinkle); }

.story-journey-arrow {
  width: 34px;
  height: 1px;
  background: var(--color-ink);
  opacity: 0;
  transform: scaleX(0);
  transform-origin: left center;
  will-change: opacity, transform;
}

.story-journey-note {
  margin-top: clamp(18px, 2.4vw, 28px);
  font-family: var(--font-body);
  font-size: var(--fs-meta);
  letter-spacing: 0.02em;
  color: var(--color-ink);
  opacity: 0;
  max-width: 46ch;
  will-change: opacity, transform;
}

/* ---- 03 — The Difference: Interactive Parts Finder demo ----
   A clean editorial product showcase, not a UI screenshot in a box: the
   real IPL sits directly on the page's own cream background (no dark
   container around it), as large as the available space allows. Copy +
   FIND/IDENTIFY/BUY sit in a narrower column *alongside* the diagram
   (not stacked above it) specifically so there's zero vertical gap
   between the heading and the diagram — they share the same row, so the
   diagram can claim the full stage height from the very top. */
.story-difference .story-stage {
  justify-content: flex-start;
  align-items: stretch;
  padding-top: calc(var(--header-height) + clamp(6px, 0.9vh, 14px));
  padding-bottom: clamp(6px, 0.9vh, 14px);
  /* The diagram is deliberately scaled a little past the edge of its own
     box below (see .ipl-imgbox) so it reads larger and sits closer to the
     copy column — overflow stays visible so that never gets clipped. The
     header above has no solid fill (mix-blend-mode: difference, see
     header.css) specifically so it stays legible over whatever scrolls
     beneath it, which is what makes a little vertical overlap safe here. */
  overflow: visible;
}

/* Interaction accent — was a MowerMerch-specific red, now the site's own
   periwinkle (var(--color-periwinkle), tokens.css) so the IPL's
   interaction layer reads as part of the FANGIN system (cream + black +
   periwinkle + acid) instead of introducing a colour found nowhere else
   on the site. Still scoped to this interactive overlay layer only and
   used sparingly (the active hotspot ring, its connector line, the REF
   label — nothing else) so it reads as a restrained accent rather than a
   wash of colour. The IPL artwork itself stays pure black-and-white (it's
   the genuine diagram) — the contrast between "old-school genuine
   diagram" and "modern ecommerce layer" is the point. Kept as a
   locally-scoped custom property (not a second definition in tokens.css)
   since every use of it lives inside this one demo — referencing the
   shared token here, rather than repeating var(--color-periwinkle) at
   every call site, keeps a single swap point if this layer's accent ever
   needs to change again without touching the site-wide token itself. */
.ipl-demo {
  --ipl-accent: var(--color-periwinkle);
  display: flex;
  flex-direction: row;
  align-items: stretch;
  /* Tightened from clamp(28px, 4.5vw, 72px) — the diagram should feel
     connected to FIND/IDENTIFY/BUY, not floating in its own column. */
  gap: clamp(18px, 2.8vw, 44px);
  flex: 1;
  min-height: 0;
  opacity: 0;
  transform: translateY(18px);
  will-change: opacity, transform;
}

.ipl-copy {
  flex: 0 0 clamp(300px, 29vw, 420px);
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-width: 0;
}

.story-kicker {
  font-family: var(--font-body);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  font-size: clamp(0.85rem, 1.15vw, 1.05rem);
  color: var(--color-ink);
  transform: translateY(10px);
  /* Opened up from clamp(6px, 0.9vw, 10px) — with the headline below now
     two short declarative lines instead of one long sentence, the
     hierarchy needs real air between "the label" and "the statement",
     not just enough to avoid touching. Mobile keeps its own tighter
     value (see the max-width:900px block further down). */
  margin-bottom: clamp(10px, 1.5vw, 18px);
  will-change: transform;
}

.story-difference .story-headline {
  /* Two short declarative lines (see data.difference.subhead, config.js)
     rather than one long sentence — sized as a strong subhead, not
     display-scale headline type, deliberately not enlarged further here
     (the brief was breathing room, not a bigger headline). line-height
     nudged up slightly from the base .story-headline's 0.9 (tuned for
     dense all-caps display type generally) so the two stacked lines read
     as a considered pair rather than crowding each other. */
  font-size: clamp(1.3rem, 1.9vw, 1.95rem);
  line-height: 1.05;
  transform: translateY(14px);
  margin-bottom: clamp(18px, 2.4vw, 28px);
  will-change: transform;
}

.story-explain {
  font-family: var(--font-body);
  font-size: clamp(0.84rem, 0.95vw, 0.95rem);
  line-height: 1.5;
  color: var(--color-ink);
  transform: translateY(12px);
  will-change: transform;
}

/* Two short lines (problem/solution), not one paragraph — a small gap
   between them reads as a deliberate beat rather than a run-on. */
.story-explain p {
  margin: 0;
}

.story-explain p + p {
  margin-top: 0.55em;
}

/* A thin vertical rail connecting the three steps, with a marker that
   travels to the live one — makes FIND -> IDENTIFY -> BUY read as a
   single journey at a glance (echoing The Solution's arrow-connected
   line) rather than three independent labels read one by one. */
.ipl-steps {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: clamp(16px, 2vw, 24px);
  padding-left: 18px;
  /* Opened up from clamp(20px, 2.6vw, 30px) for more separation from the
     copy above, part of the same desktop breathing-room pass as the
     kicker/headline margins above. Mobile keeps its own compact value
     (see the max-width:900px block further down). */
  margin-top: clamp(28px, 3.6vw, 44px);
}

.ipl-steps::before {
  content: '';
  position: absolute;
  left: 3px;
  top: 6px;
  bottom: 6px;
  width: 1px;
  background: rgba(17, 17, 17, 0.15);
}

.ipl-step {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 3px;
  /* Inactive steps still need to read clearly (not just imply their
     existence) — raised from 0.38 so FIND/IDENTIFY stay legible before
     their turn comes, while .is-active (below) remains the clear focal
     point via full opacity + the periwinkle accent. A small translateX
     lean on activation (below) gives this list its own restrained motion
     synced to the same scroll-driven state that moves the diagram's own
     hotspot/connector/card — previously only opacity + colour changed
     here, which read as the left column sitting inert while the IPL did
     all the animating. Deliberately smaller than the one-time entrance
     movement above (6px vs. 20-40px): this fires again on every hotspot
     the scripted sequence walks through, not once, so a full-size rise
     would repeat/flicker rather than read as a single considered beat. */
  opacity: 0.58;
  transform: translateX(0);
  transition: opacity 0.35s var(--ease-editorial), transform 0.35s var(--ease-editorial);
  will-change: opacity, transform;
}

.ipl-step::before {
  content: '';
  position: absolute;
  left: -18px;
  top: 5px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--color-ink);
  opacity: 0.45;
  transition: background-color 0.35s var(--ease-editorial), opacity 0.35s var(--ease-editorial), transform 0.35s var(--ease-editorial);
}

.ipl-step.is-active {
  opacity: 1;
  transform: translateX(6px);
}

.ipl-step.is-active::before {
  background: var(--color-periwinkle);
  opacity: 1;
  transform: scale(1.3);
}

.ipl-step-index {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 0.68rem;
  letter-spacing: 0.1em;
  color: var(--color-ink);
  opacity: 0.6;
}

.ipl-step-label {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: clamp(1.3rem, 1.7vw, 1.75rem);
  line-height: 1;
  color: var(--color-ink);
  transition: color 0.35s var(--ease-editorial);
}

.ipl-step.is-active .ipl-step-label { color: var(--color-periwinkle); }

.ipl-step-desc {
  font-family: var(--font-body);
  font-size: 0.76rem;
  line-height: 1.4;
  color: var(--color-ink);
  opacity: 0.6;
  max-width: 28ch;
}

/* The diagram wrap lets the real IPL claim the *entire* height of the row
   it shares with .ipl-copy — since that row (.ipl-demo) is itself flex:1
   inside the sticky stage, the diagram ends up as large as the viewport
   genuinely allows. Left-aligned (not centered) so the diagram sits right
   up against the copy column instead of floating in the middle of its own
   wide column — reads as "connected to FIND/IDENTIFY/BUY" rather than
   centered in empty space. */
.ipl-diagram-wrap {
  flex: 1;
  min-height: 0;
  display: flex;
  justify-content: flex-start;
}

/* Held to the source image's own aspect ratio (2034:2667) so it renders
   at the largest size that fits the available box in both dimensions —
   sitting directly on the page's cream background, no dark container,
   like a technical drawing printed on the page. Because this box always
   matches the image's own aspect ratio exactly, the image fills it with
   zero letterboxing, so hotspots can be positioned with plain CSS
   percentages (see .ipl-hotspot below) — no JS bounds math needed.
   `margin: 0` (not `0 auto`) so the flex-start alignment above actually
   takes effect — a flex item's own auto margins would otherwise absorb
   the free space and re-center it regardless of the parent's
   justify-content. */
.ipl-stage-wrap {
  position: relative;
  height: 100%;
  max-width: 100%;
  aspect-ratio: 2034 / 2667;
  margin: 0;
}

/* The image + hotspot layer is scaled up past its own box on top of
   that — bigger than "fill the available height" alone can give it,
   without shrinking the product card or moving the hotspot/connector
   coordinate frame (.ipl-stage-wrap, which they're positioned against,
   stays at its own unscaled size — see positionCardH() in
   mowermerch-story.js, which reads this scaled box's real rendered rect
   live so the card still centers correctly under the enlarged diagram).
   Anchored bottom-left so growth only ever extends up and to the right —
   never back toward the copy column, never down past the sticky stage's
   own bottom edge; the tightened .ipl-demo gap above is what actually
   pulls it closer to the copy column, not this anchor. Raised from 1.09
   to 1.2 (~10% further growth) now that the diagram's own background is
   transparent — the enlarged edge reads as the drawing's own linework
   extending toward the header, not a bigger white rectangle intruding on
   it, so the previous caution about header overlap matters less here. */
.ipl-imgbox {
  position: absolute;
  inset: 0;
  transform: scale(1.2);
  transform-origin: 0% 100%;
  will-change: transform;
}

.ipl-diagram-img {
  display: block;
  width: 100%;
  height: 100%;
}

.ipl-hotspots {
  position: absolute;
  inset: 0;
}

/* A ring (not a filled disc) so the real printed number stays legible
   underneath it — the hotspot marks the callout, it doesn't cover it.
   Neutral ink by default; turns periwinkle only when active, so it reads
   as a deliberate, restrained signal rather than decoration. */
.ipl-hotspot {
  position: absolute;
  width: 22px;
  height: 22px;
  margin: -11px 0 0 -11px;
  border-radius: 50%;
  cursor: pointer;
  opacity: 0;
  transform: scale(0.55);
  pointer-events: none;
  transition: opacity 0.45s var(--ease-editorial), transform 0.45s var(--ease-editorial);
  will-change: opacity, transform;
}

.ipl-hotspot::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1.5px solid var(--color-ink);
  opacity: 0.32;
  transition: border-color 0.3s var(--ease-editorial), opacity 0.3s var(--ease-editorial);
}

.ipl-hotspot::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 5px;
  height: 5px;
  margin: -2.5px 0 0 -2.5px;
  border-radius: 50%;
  background: var(--color-ink);
  opacity: 0.5;
  transition: background-color 0.3s var(--ease-editorial), opacity 0.3s var(--ease-editorial);
}

.ipl-hotspot.is-visible {
  opacity: 1;
  transform: scale(1);
  pointer-events: auto;
}

.ipl-hotspot:hover::before { opacity: 0.6; }

.ipl-hotspot.is-active {
  transform: scale(1.3);
}

.ipl-hotspot.is-active::before {
  border-color: var(--ipl-accent);
  opacity: 1;
  animation: ipl-pulse 1.5s ease-out infinite;
}

.ipl-hotspot.is-active::after {
  background: var(--ipl-accent);
  opacity: 1;
}

/* rgb(139, 140, 255) is --color-periwinkle (#8B8CFF) spelled out as raw
   components — box-shadow can't reference a CSS custom property inside
   rgba()'s own channel list, so this pulse (like ipl-pulse-ink's ink
   below) has to hardcode the colour it's echoing rather than
   var(--ipl-accent)/var(--color-periwinkle). Keep in sync with tokens.css
   if that value ever changes. */
@keyframes ipl-pulse {
  0% { box-shadow: 0 0 0 0 rgba(139, 140, 255, 0.5); }
  70% { box-shadow: 0 0 0 11px rgba(139, 140, 255, 0); }
  100% { box-shadow: 0 0 0 0 rgba(139, 140, 255, 0); }
}

/* FIND's own moment: the step that's about to be scripted appears and
   pulses in neutral ink first — a distinct beat *before* IDENTIFY turns
   it (and the connector) periwinkle, so FIND reads as its own event on
   the diagram rather than just a label change in the copy column. */
.ipl-hotspot.is-finding {
  transform: scale(1.12);
}

.ipl-hotspot.is-finding::before {
  opacity: 0.85;
  animation: ipl-pulse-ink 1.3s ease-out infinite;
}

.ipl-hotspot.is-finding::after {
  opacity: 0.85;
}

@keyframes ipl-pulse-ink {
  0% { box-shadow: 0 0 0 0 rgba(17, 17, 17, 0.35); }
  70% { box-shadow: 0 0 0 9px rgba(17, 17, 17, 0); }
  100% { box-shadow: 0 0 0 0 rgba(17, 17, 17, 0); }
}

.ipl-connector-line {
  position: absolute;
  left: 0;
  top: 0;
  height: 1px;
  background: var(--ipl-accent);
  opacity: 0;
  transform-origin: 0 50%;
  pointer-events: none;
  transition: opacity 0.35s var(--ease-editorial);
  z-index: 2;
}

.ipl-connector-line.is-open { opacity: 0.55; }

/* The product panel — ecommerce UI layered over the technical drawing,
   not a screenshot in a box: cream/white surface, a soft shadow to lift
   it off the diagram, anchored near the bottom of the diagram itself
   (not pinned beside whichever hotspot is active) so it reads as one
   stable piece of shopping UI the connector line points into. */
/* Sized to cover noticeably less of the diagram than before — width and
   every internal font-size trimmed down together. `left` is set live by
   positionCardH() in mowermerch-story.js (the 50% below is only a
   pre-JS/no-JS fallback) so it still centers correctly under the
   diagram's real rendered box now that .ipl-imgbox is scaled up past
   .ipl-stage-wrap's own unscaled width. */
.ipl-card {
  position: absolute;
  left: 50%;
  bottom: clamp(10px, 3vh, 28px);
  width: clamp(170px, 21vw, 224px);
  background: var(--color-bone);
  color: var(--color-ink);
  padding: 11px 13px 12px;
  opacity: 0;
  transform: translate(-50%, 8px);
  pointer-events: none;
  transition: opacity 0.4s var(--ease-editorial), transform 0.4s var(--ease-editorial);
  box-shadow: 0 16px 36px rgba(17, 17, 17, 0.16), 0 3px 8px rgba(17, 17, 17, 0.08);
  z-index: 3;
}

.ipl-card.is-open {
  opacity: 1;
  transform: translate(-50%, 0);
  pointer-events: auto;
}

.ipl-card-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 5px;
}

.ipl-card-ref {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 0.6rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ipl-accent);
}

.ipl-card-stock {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 0.53rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--color-ink);
  background: var(--color-acid);
  padding: 2px 7px;
  white-space: nowrap;
}

.ipl-card-stock.is-special {
  background: transparent;
  color: var(--color-ink);
  opacity: 0.55;
  padding: 2px 0;
}

.ipl-card-name {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: 0.92rem;
  line-height: 1.14;
  margin-bottom: 3px;
}

.ipl-card-oem {
  font-family: var(--font-body);
  font-size: 0.63rem;
  opacity: 0.55;
  margin-bottom: 9px;
}

.ipl-card-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 9px;
}

.ipl-card-price {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 0.94rem;
  color: var(--color-ink);
}

.ipl-card-qty {
  display: flex;
  align-items: center;
  border: 1px solid rgba(17, 17, 17, 0.18);
}

.ipl-qty-btn {
  width: 22px;
  height: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-body);
  font-size: 0.8rem;
  color: var(--color-ink);
}

.ipl-qty-value {
  min-width: 20px;
  text-align: center;
  font-family: var(--font-body);
  font-size: 0.7rem;
  border-left: 1px solid rgba(17, 17, 17, 0.18);
  border-right: 1px solid rgba(17, 17, 17, 0.18);
  padding: 0 2px;
}

.ipl-card-cta {
  display: block;
  width: 100%;
  padding: 8px 8px;
  background: var(--color-ink);
  color: var(--color-bone);
  text-align: center;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 0.64rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  white-space: nowrap;
}

.ipl-card-demo-note {
  margin-top: 7px;
  font-family: var(--font-body);
  font-size: 0.54rem;
  font-style: italic;
  opacity: 0.4;
  text-align: center;
}

/* ------------------------------------------------------------------
   Mobile-only cinematic camera sequence — a dedicated interaction, not
   the desktop diagram/card above shrunk to fit. Built on a second,
   independent copy of the same real IPL image + the same hotspot
   percentage coordinates (main.js; see the comment on the markup in
   index.html). Hidden entirely on desktop; shown and sized in the
   max-width: 900px query below. The scroll-driven pan/zoom/bottom-sheet
   logic lives in mowermerch-story.js's applyMobileCamera().
   ------------------------------------------------------------------ */
.ipl-mobile-scene {
  display: none;
}

/* ---- 04 — The Result ----
   Closes the case study proper: $1M+ sits fixed at the centre of an
   interactive drag-and-snap board of twelve plain-English ecommerce
   building blocks, resolving into LOOK GOOD./WORK HARDER. once enough
   have gathered — see initResult() in mowermerch-story.js for the full
   drag/magnet/snap/auto-gather logic. Stacks to a dedicated
   fixed-quadrant mobile composition (see the max-width:900px block near
   the bottom of this file), not the desktop scatter shrunk down. */
/* ---- Result: interactive "brain dump" board ----
   Replaces the old scroll-settle mood board of MowerMerch screenshots
   (real/placeholder screenshots scattered art-directed around a static
   headline) with a drag-and-snap interaction: $1M+ sits fixed at the
   stage's centre as a magnetic target, and twelve plain-English ecommerce
   building blocks (cfg.mowermerchStory.result.board, config.js) scatter
   around it — deliberately overlapping, mixed widths/rotations, "Nicole's
   ecommerce brain dump" rather than a service grid. Grab one and drag it
   toward the centre and it snaps into a gathered ring with a spring and a
   thin connecting thread (like string on a cork board); leave the board
   alone and initResult()'s scroll-driven auto-gather (mowermerch-story.js)
   brings the rest in on its own. Once enough have gathered, LOOK GOOD./
   WORK HARDER. resolves in the centre in place of the metric. See
   initResult() for all position/drag/magnet/snap logic — this file only
   supplies the visual language. */
.story-result .story-stage {
  justify-content: center;
  align-items: center;
  gap: clamp(18px, 2.6vh, 34px);
}

.story-result-intro {
  text-align: center;
  max-width: 620px;
}

.story-result-eyebrow,
.story-result-heading,
.story-result-intro-supporting {
  opacity: 0;
  transform: translateY(16px);
  will-change: opacity, transform;
}

.story-result-eyebrow {
  margin-bottom: clamp(8px, 1.2vw, 14px);
}

.story-result-heading {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: clamp(1.8rem, 3.4vw, 2.8rem);
  line-height: 1.03;
  color: var(--color-ink);
  margin: 0 0 10px;
}

.story-result-intro-supporting {
  font-family: var(--font-body);
  font-size: clamp(0.95rem, 1.2vw, 1.1rem);
  color: var(--color-ink);
  margin: 0;
}

.story-result-board-stage {
  position: relative;
  width: 100%;
  max-width: 1180px;
  height: min(72vh, 680px);
  container-type: inline-size;
}

.story-result-threads {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 5;
}

/* Thin dashed "string" from a gathered piece back to the centre — a quiet
   proximity cue, not a flowchart connector (per the brief's explicit "do
   not turn it into a complicated flowchart"). */
.bd-thread {
  fill: none;
  stroke: var(--color-ink);
  stroke-width: 1.4;
  stroke-dasharray: 3 5;
  opacity: 0;
  transition: opacity 0.4s var(--ease-editorial);
}

.bd-thread.is-on {
  opacity: 0.4;
}

/* ---- Centre target: $1M+ ----
   The ring is a solid ink disc now (was a dashed outline) — the brief
   ("make the circle black... all the words in the middle fit into the
   circle") asked for a real filled target rather than an outline, and for
   BOTH lockups it ever shows — the resting $1M+/label pair AND the
   resolved LOOK GOOD./WORK HARDER. payoff — to sit fully inside it. That's
   a stricter constraint than the old dashed-outline version had (which
   deliberately let the payoff spill past the ring — see prior comment
   history): the payoff is taller than the metric+label, so the ring here
   is sized for the payoff's stacked lines (lines + two-line supporting
   copy), not just the metric, and the metric/label sizes below are chosen
   to comfortably clear the same circle with room to spare. Confirmed by
   measuring live glyph bounding boxes (Range.getBoundingClientRect(), not
   the CSS box) against the ring's own circle equation, both states, at
   widths from 320px to 1920px. Colour-wise this is now a solid ink block,
   so every child text colour had to move off the old "ink text on bone"
   assumption to one of the brand's own colours that reads on ink: bone
   for neutral copy, periwinkle for the metric (unchanged) and the
   supporting line's emphasis, acid for "Work harder." per the brief —
   acid is the one brand colour that's unreadable on bone but was made for
   exactly this ink-block pairing (see --color-acid in tokens.css). */
.story-result-center {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 20;
  width: 280px;
  /* Anton (the metric's face) renders glyph ink well past its own nominal
     line-height box — confirmed by measuring the value's real rendered
     bounds, not assumed — enough that with this block centred purely by
     its box height, the top of "$1M+" itself poked just past the ring's
     top edge even though the box model said it fit. This padding-top
     shifts the whole lockup down inside that centred box so the actual
     ink clears the ring's top arc in both the resting and resolved
     states, checked against the bigger, content-driven ring below. */
  padding-top: 22px;
  text-align: center;
  pointer-events: none;
}

.story-result-center-ring {
  position: absolute;
  left: 50%;
  top: 50%;
  /* Sized for the taller of the two things it ever has to hold — the
     resolved payoff's stacked lines, not just the resting $1M+/label —
     since both now have to stay fully inside it (see the section comment
     above). */
  width: 300px;
  height: 300px;
  transform: translate(-50%, -50%);
  background: var(--color-ink);
  border: 1.5px solid var(--color-ink);
  border-radius: 50%;
  box-shadow: 0 16px 34px rgba(17, 17, 17, 0.22);
  transition: border-color 0.4s ease, box-shadow 0.4s ease, transform 0.6s var(--ease-editorial);
}

/* A subtle "it wants to come home" cue while a dragged piece is inside
   the magnet radius (see MAGNET_FRAC, initResult()) — the ring brightens
   and grows very slightly rather than anything more literal. Now that the
   disc is a solid fill rather than an outline, the cue moves to a
   periwinkle edge + glow instead of the old border-colour swap alone. */
.story-result-center.is-magnet .story-result-center-ring {
  border-color: var(--color-periwinkle);
  box-shadow: 0 0 0 3px var(--color-periwinkle), 0 16px 34px rgba(17, 17, 17, 0.22);
  transform: translate(-50%, -50%) scale(1.06);
}

.story-result-value {
  position: relative;
  display: block;
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: clamp(1.9rem, 3.4vw, 2.8rem);
  line-height: 1;
  color: var(--color-periwinkle);
  transition: transform 0.5s var(--ease-editorial), opacity 0.5s var(--ease-editorial);
}

/* Deliberately its own smaller size/tracking here rather than inheriting
   the shared .eyebrow class's 12px/0.12em (still used for font-weight/
   text-transform/font-family) — a 3-word label needs to be noticeably
   smaller than eyebrow labels elsewhere on the page to have any chance of
   fitting inside a circle this size. max-width forces the two-line wrap
   this relies on; margin's auto left/right centres that narrower box
   inside the wider .story-result-center (text-align:center alone only
   centres inline content, not a block-level child with its own width).
   Colour is bone, not ink — the ring is now a solid ink disc, so ink text
   would be invisible on it. */
.story-result-label {
  position: relative;
  display: block;
  font-size: 0.64rem;
  letter-spacing: 0.06em;
  color: var(--color-bone);
  /* Anton (the metric's display face, --font-display) renders with real
     glyph ink extending well past its own line-height box — confirmed by
     comparing this span's rendered glyph bounds against $1M+'s, not just
     eyeballing it — so an 8px margin-top here (a plain visual gap for most
     type) still let the two lines' actual ink overlap. Pushed out to 28px
     to give real clearance; there's plenty of room left before the ring's
     own bottom edge at every width this was checked against. Collapses
     down to a sliver on .is-resolved (below) so the payoff isn't left
     sitting under a tall gap where this used to be. */
  margin: 28px auto 0;
  max-width: 140px;
  /* max-height here (generous enough to clear the wrapped label at this
     size — measured, not guessed, after the mobile override's own figure
     turned out too tight for a 3-line wrap and clipped real text; see its
     comment) exists so the is-resolved rule below can collapse it to 0 —
     opacity alone hides the text but leaves its box's real height
     reserved, which was quietly pushing the payoff content that appears
     in its place further down than it needed to be, close enough to the
     ring's bottom curve to read as cramped/clipped on mobile. A cap here
     bigger than the real content has no visual effect on the resting
     state, so this errs generous rather than cutting it close again. */
  max-height: 48px;
  overflow: hidden;
  transition: opacity 0.5s var(--ease-editorial), margin-top 0.5s var(--ease-editorial), max-height 0.5s var(--ease-editorial);
}

/* The payoff — hidden until initResult() adds .is-resolved to
   .story-result-center once RESOLVE_THRESHOLD pieces have gathered
   (manually, via the scroll-driven auto-gather, or both). Now constrained
   to the same solid ring the resting metric sits in (see the section
   comment above) rather than being allowed to spill past it. */
.story-result-payoff {
  position: relative;
  margin-top: 4px;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.7s var(--ease-editorial), transform 0.7s var(--ease-editorial);
}

/* !important on opacity/transform here: initResult()'s own scroll-scrubbed
   timeline (the "Metric fade" tween in mowermerch-story.js) holds this
   same element at a fixed inline opacity (0.7) for virtually the whole
   chapter, and an inline style otherwise always wins over a class
   selector like this one regardless of source order — without
   !important, resolving the board while mid-chapter (the normal case;
   gathering happens well before the chapter's own scroll-out at progress
   0.94) left the metric sitting at that dimmed-but-still-full-size inline
   opacity forever, never actually shrinking/fading into the payoff below
   it. Confirmed via computed-style inspection, not assumed — this was
   silently not working before this pass touched it. */
.story-result-center.is-resolved .story-result-value {
  transform: scale(0.72) translateY(-6px) !important;
  opacity: 0.55 !important;
}

.story-result-center.is-resolved .story-result-label {
  opacity: 0 !important;
  /* Collapses the label's own reserved space once it's invisible, so the
     payoff moves up to sit close under the ghosted metric instead of
     leaving a tall empty gap where the label used to be — needed once the
     payoff also has to fit inside the ring rather than spilling past it.
     max-height:0 (paired with the base rule's overflow:hidden) is the part
     that actually reclaims the space; margin-top alone left the label's
     own two-line box still taking up room even at opacity 0, which is
     what was crowding the payoff text down toward the ring's bottom edge
     on mobile. */
  margin-top: 2px;
  max-height: 0;
}

.story-result-center.is-resolved .story-result-payoff {
  opacity: 1;
  transform: translateY(0);
}

.story-result-payoff-lines {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: clamp(1.2rem, 2.3vw, 1.62rem);
  line-height: 1.05;
  margin: 0;
}

.story-result-payoff-lines span {
  display: block;
  color: var(--color-bone);
}

/* "Work harder." per the brief — full-brightness acid reads cleanly here
   because it's sitting on the solid ink disc, exactly the pairing
   --color-acid's own definition (tokens.css) calls out as the one place
   acid works as text. */
.story-result-payoff-lines span:last-child {
  color: var(--color-acid);
}

.story-result-payoff-supporting {
  margin: 7px auto 0;
  max-width: 190px;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.62rem;
  line-height: 1.42;
  color: rgba(243, 239, 231, 0.68);
}

.story-result-payoff-supporting b {
  color: var(--color-periwinkle);
}

/* ---- Draggable building blocks ---- */
.story-result-board {
  position: absolute;
  inset: 0;
}

.bd-block {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 10;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  will-change: transform, left, top;
}

/* Deliberately plain "index card on a board" framing (sharp corners, a
   hairline border, a barely-there shadow) — same restraint the old
   .board-frame used, so this reads as evidence pinned to a board rather
   than a SaaS feature-card grid. */
.bd-block-card {
  position: relative;
  background: #e4e2da; /* a step darker than --color-bone, same "distinct screen/note against the page" convention the old placeholder panels used */
  border: 1px solid rgba(17, 17, 17, 0.16);
  border-radius: 2px;
  box-shadow: 0 10px 22px rgba(17, 17, 17, 0.09);
  padding: 12px 13px 11px;
  text-align: left;
  transition: box-shadow 0.25s ease, width 0.3s var(--ease-editorial), padding 0.3s var(--ease-editorial);
}

.bd-block.is-dragging .bd-block-card {
  box-shadow: 0 22px 40px rgba(17, 17, 17, 0.22);
}

.bd-block.is-snapped .bd-block-card {
  border-color: var(--color-periwinkle);
}

/* A brief, deliberately subtle emphasis the moment a piece connects (the
   brief: "reveal its plain-English line more prominently for a moment...
   keep this very subtle") — a periwinkle outline and a darker explanation
   line for ~2s (see revealMicrocopy(), initResult()), then back to rest. */
.bd-block.is-revealed .bd-block-card {
  box-shadow: 0 0 0 2px var(--color-periwinkle), 0 18px 30px rgba(17, 17, 17, 0.16);
}

.bd-block.is-revealed .bd-block-expl {
  color: var(--color-ink);
}

/* Small "washi tape" accent pinning each note to the board — alternates
   acid/periwinkle (renderStoryResultBoard(), main.js) so the board reads
   as a physical vision board, not a uniform card system. Decorative only. */
.bd-tape {
  position: absolute;
  top: -9px;
  left: 50%;
  width: 46px;
  height: 18px;
  transform: translateX(-50%) rotate(var(--tape-rot, -4deg));
  opacity: 0.85;
}

.bd-tape.acid {
  background: rgba(223, 255, 0, 0.85);
}

.bd-tape.peri {
  background: rgba(139, 140, 255, 0.55);
}

.bd-block-term {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: 0.98rem;
  line-height: 1.02;
  color: var(--color-ink);
  margin: 0 0 5px;
}

/* Deliberately plainer than the term above (regular weight, sentence
   case, muted) for a more human/informal register — see the brief's "if
   appropriate, use a more handwritten/note-like treatment... but do not
   use an unreadable novelty font." This site self-hosts exactly two type
   families (Anton/Inter — see tokens.css's own reasoning for why no
   webfont is ever loaded live), so the informal read here comes from
   weight/case/colour contrast against the bold display term rather than
   a third, unreviewed font file. */
.bd-block-expl {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 0.85rem;
  line-height: 1.3;
  color: rgba(17, 17, 17, 0.62);
  margin: 0;
  transition: color 0.3s ease;
}

.bd-block[data-size="n"] .bd-block-card {
  width: 150px;
}

.bd-block[data-size="m"] .bd-block-card {
  width: 178px;
}

.bd-block[data-size="w"] .bd-block-card {
  width: 212px;
}

/* Once gathered, every piece compacts to the same small footprint
   regardless of its scattered size — reads as "resolved into the result"
   rather than just relocated, and keeps twelve items legible in a loose
   ring instead of a pile-up. */
.bd-block.is-snapped .bd-block-card {
  width: 122px !important;
  padding: 8px 9px 7px;
}

.bd-block.is-snapped .bd-block-term {
  font-size: 0.72rem;
  margin-bottom: 3px;
}

.bd-block.is-snapped .bd-block-expl {
  font-size: 0.72rem;
  line-height: 1.16;
}

.bd-block.is-snapped .bd-tape {
  width: 34px;
  height: 13px;
  top: -6px;
}

@media (prefers-reduced-motion: reduce) {
  .bd-block,
  .bd-block-card,
  .bd-thread,
  .story-result-value,
  .story-result-label,
  .story-result-payoff,
  .story-result-center-ring {
    transition: none;
  }
}

/* ==========================================================================
   HOMEPAGE ENDING — portfolio bridge (05) + final CTA (06)
   ------------------------------------------------------------------------
   Not MowerMerch content (see cfg.homepageEnding, config.js) but built on
   the exact same .story-chapter/.story-stage sticky-scrub scaffolding
   above, so it's documented in this same file rather than a separate
   stylesheet. Centred, not left-aligned like the case-study chapters
   above — a deliberate visual break signalling "this is the finale, not
   another chapter" — with generous whitespace and no cards/thumbnails,
   per the brief. Every entrance-choreographed element below defaults to
   opacity:0 in its base rule (not scoped to a min-width query) because,
   unlike the Solution -> Difference transition earlier in this file,
   these reveals run identically at every viewport width — see the long
   comment on initResultToBridge() in mowermerch-story.js for why that
   transition needed a desktop/mobile split and this one doesn't.
   ========================================================================== */

.story-bridge .story-stage,
.story-final-cta .story-stage {
  align-items: center;
  text-align: center;
}

/* ---- 05 — Portfolio bridge ---- */
.story-bridge-lines {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  margin-bottom: clamp(28px, 4vw, 48px);
}

.story-bridge-line {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: clamp(2.6rem, 6vw, 6.2rem);
  line-height: 0.96;
  letter-spacing: -0.01em;
  color: var(--color-ink);
  opacity: 0;
  will-change: opacity, transform;
}

/* A proper high-impact CTA now, not a plain text link — acid block, ink
   text, pill-shaped (border-radius: 999px, matching the header nav's own
   .nav-cta pill rather than the final CTA's deliberately sharp-cornered
   "stamped poster block" below, so the two acid moments on this page read
   as related but not identical). Generous horizontal padding per the
   brief; no low-contrast acid-on-cream anywhere — the acid only ever
   appears as this solid fill, ink text on top, same reasoning as
   .story-final-btn above.

   Entrance/exit opacity+y lives on .story-bridge-cta-wrap (see the
   comment on it in index.html), not here — this element's own `transform`
   is owned entirely by cursor.js's magnetic-button module (hover lift +
   tiny pull toward the cursor, desktop pointer devices only). Splitting
   the two onto separate elements is what lets a scroll-scrubbed GSAP
   tween and a mousemove-driven hover tween coexist without one
   overwriting the other's inline `transform` on the same element. */
.story-bridge-cta-wrap {
  display: inline-flex;
  opacity: 0;
  will-change: opacity, transform;
}

.story-bridge-cta {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  font-family: var(--font-body);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  font-size: clamp(0.95rem, 1.3vw, 1.15rem);
  color: var(--color-ink);
  background: var(--color-acid);
  text-decoration: none;
  padding: clamp(16px, 1.8vw, 22px) clamp(32px, 3.6vw, 52px);
  border-radius: 999px;
  transform-origin: center;
  will-change: transform;
}

.story-bridge-cta-arrow {
  display: inline-block;
  transition: transform var(--duration-base) var(--ease-editorial);
}

@media (hover: hover) {
  .story-bridge-cta:hover .story-bridge-cta-arrow,
  .story-bridge-cta:focus-visible .story-bridge-cta-arrow {
    transform: translateX(4px);
  }
}

/* cursor.js skips its magnetic-button lift entirely under reduced motion
   (see initMagneticButtons() there), so this button would otherwise have
   no hover feedback at all for those users — a plain, motion-free colour
   change stands in for it. */
@media (prefers-reduced-motion: reduce) {
  .story-bridge-cta:hover,
  .story-bridge-cta:focus-visible {
    box-shadow: inset 0 0 0 2px var(--color-ink);
  }
}

/* ---- 06 — Final CTA ----
   The homepage's single biggest closing moment — see initFinalCta() in
   mowermerch-story.js for the 4-beat reveal. */
.story-final-lines {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  margin-bottom: clamp(24px, 3.4vw, 40px);
}

.story-final-line {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: clamp(2.2rem, 5vw, 5rem);
  line-height: 0.98;
  letter-spacing: -0.01em;
  color: var(--color-ink);
  opacity: 0;
  will-change: opacity, transform;
}

/* "Amazing." — periwinkle, same scale as the question above it (this is a
   reply to that question, not a new escalation on its own — "Let's fang
   it." below is the actual escalation). */
.story-final-accent {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: clamp(2.2rem, 5vw, 5rem);
  line-height: 0.98;
  letter-spacing: -0.01em;
  color: var(--color-periwinkle);
  opacity: 0;
  will-change: opacity, transform;
}

/* The payoff. Sized off the hero's own --fs-hero-display token rather
   than any of the .story-headline-family scales used elsewhere in this
   file — deliberately built to rival the hero itself, per the brief's
   "one of the strongest typographic moments on the whole site". */
.story-final-payoff {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: var(--fs-hero-display);
  line-height: 0.88;
  letter-spacing: -0.01em;
  color: var(--color-ink);
  margin-top: clamp(6px, 1vw, 14px);
  opacity: 0;
  will-change: opacity, transform;
}

.story-final-supporting {
  font-family: var(--font-body);
  font-size: clamp(1rem, 1.4vw, 1.25rem);
  line-height: 1.5;
  color: var(--color-ink);
  max-width: 52ch;
  margin: 0 auto clamp(28px, 4vw, 44px);
  opacity: 0;
  will-change: opacity, transform;
}

/* The one filled, block-colour moment on the whole site — acid as a solid
   button background with ink text, exactly per the brief (acid fails
   contrast as small text directly on cream; a solid block sidesteps that
   entirely rather than darkening the brand colour). Sharp corners (no
   border-radius) rather than the header nav's own pill shape — reads as a
   stamped poster block, not UI chrome, matching the kw-block-acid
   "highlighter" treatment used earlier in this file. No shadow/gradient
   per the brief; hover is a restrained scale, nothing gimmicky. */
.story-final-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  font-family: var(--font-body);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  font-size: clamp(0.95rem, 1.3vw, 1.15rem);
  color: var(--color-ink);
  background: var(--color-acid);
  text-decoration: none;
  padding: clamp(16px, 1.8vw, 22px) clamp(32px, 3.6vw, 52px);
  border-radius: 0;
  opacity: 0;
  transform-origin: center;
  transition: transform 0.3s var(--ease-editorial);
  will-change: opacity, transform;
}

.story-final-btn-arrow {
  display: inline-block;
  transition: transform var(--duration-base) var(--ease-editorial);
}

@media (hover: hover) {
  .story-final-btn:hover,
  .story-final-btn:focus-visible {
    transform: scale(1.045);
  }

  .story-final-btn:hover .story-final-btn-arrow,
  .story-final-btn:focus-visible .story-final-btn-arrow {
    transform: translateX(4px);
  }
}

/* ---- Reduced-motion / static fallback ----
   One combined, always-visible read-through of the whole story — no
   scroll-scrubbed motion. Populated by main.js; interactive .story is
   hidden via JS in this mode (mirrors the hero/transition/work pattern). */
.story-static-fallback {
  display: none;
  background: var(--color-bone);
  padding: clamp(60px, 9vw, 110px) var(--edge-gutter);
}

.story-static-fallback.is-active { display: block; }

.story-static-fallback .story-static-block {
  max-width: 760px;
  margin: 0 auto;
  padding: clamp(40px, 6vw, 70px) 0;
  border-top: 1px solid rgba(17, 17, 17, 0.12);
}

.story-static-fallback .story-static-block:first-child {
  border-top: 0;
}

.story-static-fallback .story-static-eyebrow {
  opacity: 0.5;
  margin-bottom: 14px;
}

.story-static-fallback .story-static-headline {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 400;
  font-size: clamp(2.2rem, 4.6vw, 3.6rem);
  line-height: 0.95;
  margin-bottom: 16px;
}

.story-static-fallback .story-static-copy {
  font-family: var(--font-body);
  font-size: 1.05rem;
  line-height: 1.55;
  margin-bottom: 10px;
}

.story-static-fallback .story-static-keywords {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 18px;
  margin-top: 18px;
}

.story-static-fallback .story-static-keywords span {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: 1.1rem;
}

.story-static-fallback .story-static-metric {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: clamp(3rem, 7vw, 5.5rem);
  line-height: 0.9;
  color: var(--color-periwinkle);
  margin-bottom: 8px;
}

/* Final CTA's "Amazing." gets the same treatment as the metric above — the
   fallback's one other periwinkle accent, matching the live section's own
   single-word emphasis colour so the fallback isn't a strictly flatter
   read of the same "strongest typographic moment" the brief called for. */
.story-static-fallback .story-static-accent {
  color: var(--color-periwinkle);
}

/* The Result's twelve building blocks (see renderStoryStaticFallback(),
   main.js) as a plain static list of term + explanation pairs, not the
   interactive drag-and-snap board — a linear read-through has no pointer
   to hang the magnet/snap interaction off, so this just lists every block
   once, in the same order the live section scatters them. */
.story-static-fallback .story-static-board {
  display: flex;
  flex-wrap: wrap;
  gap: 20px 28px;
  margin-top: 18px;
}

.story-static-fallback .story-static-board-item {
  width: min(100%, 260px);
}

.story-static-fallback .story-static-board-term {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: 1rem;
  line-height: 1.1;
  color: var(--color-ink);
  margin: 0 0 4px;
}

/* Bridges 02 — The Solution into 03 — The Difference (901px and up —
   tablet included, unlike the 1024px-only IPL size cap below, since the
   entrance choreography itself doesn't depend on the diagram's final
   settled size). On mobile, "03"/the kicker/the headline/the explain copy/
   the FIND-IDENTIFY-BUY steps/the diagram all still ride in together on
   .ipl-demo's own single opacity fade (see the MOBILE_MQ branch inside
   initDifference(), mowermerch-story.js — completely untouched). Here,
   each becomes its own independently-staggered reveal instead, driven by a
   dedicated ScrollTrigger (initSolutionToDifference() in the same file)
   that owns exactly the ~1-viewport-height of ordinary scroll between
   Solution's sticky stage releasing and Difference's own sticky stage
   locking in — the stretch that previously rendered as dead, blank cream,
   since nothing was scroll-linked to it. .ipl-demo's own opacity/transform
   default (above) is untouched: that new timeline sets it to its resting
   state immediately, and the property is still separately used, unrelated
   to any of this, for the fade into 04 — The Result. */
@media (min-width: 901px) {
  .story-difference .story-index {
    opacity: 0;
    transform: translateY(28px);
    will-change: opacity, transform;
  }

  .story-kicker {
    opacity: 0;
  }

  .story-difference .story-headline {
    opacity: 0;
  }

  .story-explain {
    opacity: 0;
  }

  .ipl-steps {
    opacity: 0;
    transform: translateY(28px);
    will-change: opacity, transform;
  }

  .ipl-diagram-wrap {
    opacity: 0;
    transform: translateY(40px);
    will-change: opacity, transform;
  }
}

/* Desktop-only IPL rebalance (1024px and up specifically — the 901–1023px
   tablet range keeps the base rules above, untouched, same as before this
   pass). The base .ipl-stage-wrap/.ipl-imgbox rules above size the
   diagram to the *entire* height of its row plus an extra 1.2x scale-up,
   which read as the diagram dominating the whole section rather than
   sitting as a supporting visual beside the copy. This caps and
   left-leans it instead, with real (but not excessive) margin around it.

   Sizing here went through two rounds: the first cap (clamp(320px, 32vw,
   520px) / min(58vh, 620px), justify-content: center) undershot — it read
   as too small and too far from the copy column, floating in the
   right-hand area rather than relating to it. This is that same cap
   raised ~17.5% across the board (a size between the original
   full-height/1.2x-scale version and that first small/centred one) with
   justify-content switched from center to flex-start, so the diagram
   sits close to the copy column (the .ipl-demo gap above is the only
   space between them) with its own breathing room now on the right
   instead of split evenly on both sides — reads as "belonging to" the
   copy rather than centred alone in the column.

   Nothing about the hotspot/connector/product-card system needed to
   change to make this safe: hotspots are positioned with plain CSS
   percentages against .ipl-stage-wrap's own box (see the comment on that
   rule above), and the connector line + product card position themselves
   every time from a live getBoundingClientRect() read of the rendered
   diagram (centerOf()/positionCardH() in mowermerch-story.js) rather than
   any hardcoded size/scale assumption — so resizing or repositioning the
   box here, they simply re-measure and keep lining up correctly. */
@media (min-width: 1024px) {
  /* Widened from clamp(18px, 2.8vw, 44px) (the base .ipl-demo value) —
     shifts the whole diagram column further right, away from the copy
     column, without touching either column's own width. Desktop-only:
     the 901-1023px tablet range keeps the tighter base gap. */
  .ipl-demo {
    gap: clamp(30px, 4.2vw, 68px);
  }

  .ipl-diagram-wrap {
    justify-content: flex-start;
    align-items: center;
  }

  .ipl-stage-wrap {
    /* height: auto (not 100%) + these caps let aspect-ratio settle the
       box at the largest size that still fits both bounds. Raised twice
       now on feedback that the diagram read too small: 610px/68vh ->
       650px/70vh, then a further explicit +10% -> 715px/77vh (max-width
       412px/44vw min/mid, up from 375px/40vw). Still well under the
       source image's native 2034px width even at the top of this range,
       so this is still comfortably a downscale, not an upscale. */
    height: auto;
    width: 100%;
    max-width: clamp(412px, 44vw, 715px);
    max-height: min(77vh, 836px);
    margin: 0;
  }

  .ipl-imgbox {
    /* The base rule's 1.2x scale-up (and its bottom-left anchor, which
       let growth extend past the box on purpose) existed specifically to
       make a height-constrained diagram read larger — not needed now that
       .ipl-stage-wrap's own cap above is doing that job directly; the
       image stays exactly inside its bounds so the breathing room around
       it is real. */
    transform: none;
  }

  /* Card grows with the diagram, but more conservatively (~10% vs. the
     diagram's ~17.5%) so it stays the "compact, elegant" supporting UI
     it was always meant to be, not a second focal point. Only its own
     footprint changes here — every internal font-size is untouched. */
  .ipl-card {
    width: clamp(188px, 23vw, 247px);
    padding: 12px 15px 13px;
  }
}

@media (max-width: 900px) {
  /* Desktop-first: the layouts above are tuned for wide viewports.
     Tablet/mobile composition to be designed separately — temporary
     safety net only, same as hero/transition/work.

     Mobile polish pass: these three (Dream/Solution/Result) previously ran
     LONGER than desktop (+120/+80/+80vh) despite having simpler, more
     compact mobile content — each chapter's own GSAP timeline (see
     mowermerch-story.js) has fixed dead gaps between keyframes (e.g. in
     Solution, nothing animates between the copy finishing and the journey
     line starting), and inflating the chapter's scroll length only stretches
     those already-dead gaps into more scrolling with nothing happening —
     exactly the "blank page" / "dead scrolling" feeling reported ahead of
     the Interactive Parts Finder. Trimmed below desktop's own values instead
     (mobile has no side-by-side layout to fill, so needs less runway, not
     more). .story-difference keeps its own larger mobile figure — that
     chapter runs a dedicated mobile camera sequence (pan/zoom/hotspot
     dive), not a shrunk copy of desktop, and genuinely uses the extra
     length. */
  .story-dream { height: 460vh; }
  .story-solution { height: 260vh; }
  .story-difference { height: 680vh; }
  /* Shortened alongside the desktop cut above (620vh -> 260vh, same file,
     .story-result base rule): the drag/magnet/snap/idle-timer interaction
     itself is entirely scroll-position-independent (initResult(),
     mowermerch-story.js — matches the reviewed prototype exactly, no
     scroll-linked auto-gather any more), so this chapter's own
     scroll-reserved height only needs to carry the intro entrance and the
     exit fade, not a multi-screenshot crossfade sequence. Kept a little
     taller than desktop's 260vh purely to give the pinned window itself
     comfortable room on a slower touch-scroll cadence. */
  .story-result { height: 200vh; }
  /* Homepage ending — same modest trims as the rest of this block, same
     reasoning (mobile has no side-by-side layout to fill, so needs less
     runway than desktop). */
  .story-bridge { height: 180vh; }
  .story-final-cta { height: 340vh; }

  /* Header-clearance fix (mobile polish pass): the base .story-stage rule
     above centres its content vertically (justify-content: center) inside
     a hard 100vh box with overflow:hidden. On desktop that's safe because
     the content is short relative to the viewport; on mobile, Dream's
     headline+copy+keyword-reveal stack, Solution's headline+copy+journey
     line, and Result's headline+metric+lines can all run taller than
     (100vh − header height) at narrow/short viewports. Centred content
     that's taller than its box overflows equally both up and down — the
     "up" half lands directly under the fixed FANGIN header (position:fixed,
     see header.css), which is exactly the reported "logo overlapping
     content, particularly around THE SOLUTION" bug. Same fix already used
     for .story-difference below: let the stage grow to fit its content
     instead of clipping/centring it, with a guaranteed header-clearing
     top padding, so a heading can never land under the header. */
  .story-dream .story-stage,
  .story-solution .story-stage,
  .story-result .story-stage,
  .story-bridge .story-stage,
  .story-final-cta .story-stage {
    height: auto;
    min-height: 100vh;
    justify-content: flex-start;
    padding-top: calc(var(--header-height) + clamp(10px, 2.4vh, 22px));
    padding-bottom: clamp(16px, 3vh, 28px);
    overflow: visible;
  }

  /* Result — a genuinely different touch composition (per the brief:
     "not just a shrunk desktop version"), not the desktop scatter resized
     down: mobile shows only 4 blocks at a time via a fixed-quadrant queue
     (MOBILE_HOME_SLOTS, promoteFromQueue(), mowermerch-story.js) — as each
     visible piece snaps in, the next of the 12 slides into its freed
     quadrant — so touch targets stay comfortably large and explanations
     stay readable instead of shrinking into tiny floating labels. */
  .story-result-intro {
    max-width: 88vw;
  }

  /* Fix for reported bug: "the ... brain board sits too low and not
     centred" on mobile. The shared header-clearance rule above
     (.story-dream/.story-solution/.story-result/.story-bridge/
     .story-final-cta .story-stage) switched this stage from a hard
     100vh/overflow:hidden box to height:auto + min-height:100vh +
     overflow:visible specifically so tall content never gets clipped —
     but Result's own stack (intro copy + this board-stage, at a fixed
     78vh/620px height) was tall enough that it routinely ran past ONE
     real mobile viewport anyway. Because .story-stage is
     `position: sticky` (base rule above), content that overflows the
     visible viewport while the chapter is pinned isn't reachable by
     scrolling further — the sticky box stays stuck at top:0 for
     virtually the whole pinned window, so the overflow (the board-
     stage's own lower portion, where the two bottom MOBILE_HOME_SLOTS
     quadrants at t:85/83 live) sits below the fold the entire time, not
     just briefly — which is exactly "sticky notes cut off at the bottom
     of the screen" from the bug report's screenshots. The $1M+ circle is
     always centred within its OWN board-stage box (.story-result-center,
     left/top:50%), so when the box's bottom half is pushed off-screen
     like this, the circle reads as sitting low in whatever's actually
     visible, not centred in the viewport, even though it's centred in a
     box you can't see all of.

     Fixed by giving the board-stage `flex: 1` instead of a fixed vh/px
     height: it has no in-flow content of its own (every child —
     .story-result-threads/-center/-board — is position:absolute, so its
     old height had to be hand-set), so with flex-grow it simply claims
     whatever vertical room is actually left after the intro copy inside
     this sticky stage's own min-height:100vh floor, on every device,
     rather than a fixed number tuned against a guess at "typical" mobile
     viewport height that a shorter phone (or a taller intro, after a
     future copy change) could once again outrun. That also directly
     fixes the centring complaint: the $1M+ circle sits at 50% of THIS
     box, and the box now runs from just under the intro copy to the
     stage's own bottom padding, so the circle lands in the middle of the
     actual remaining screen space instead of a fixed-height box that may
     or may not reach the bottom of the viewport. max-height keeps it from
     growing unreasonably large on a tall/short-content device. */
  .story-result .story-stage {
    justify-content: flex-start;
    gap: clamp(10px, 1.4vh, 18px);
  }

  .story-result-board-stage {
    flex: 1 1 auto;
    min-height: 0;
    height: auto;
    max-height: 620px;
    max-width: 100%;
  }

  .story-result-center {
    width: clamp(140px, 42vw, 170px);
    padding-top: 12px;
  }

  /* Same "solid disc, everything it ever shows fits inside it" sizing as
     the desktop rule above, re-tuned for mobile's own numbers — sized for
     the resolved payoff's stacked lines, not just the resting metric.
     Bug fix: was a flat 248px, which on a narrow phone (stage ~335px wide
     at a 375px viewport) left barely 40px of clearance on each side —
     not enough for the four MOBILE_HOME_SLOTS cards to sit clear of it,
     so the ring visibly covered them. Scales with viewport width instead
     of a fixed px so it shrinks further on genuinely narrow phones
     (down to the 190px floor) rather than staying oversized relative to
     the stage; paired with MOBILE_HOME_SLOTS being pushed further into
     the stage's corners (mowermerch-story.js). Bumped slightly from
     138/46vw/220 (still well clear of the MOBILE_HOME_SLOTS cards this
     was tuned against — see the bug-fix note above) to give the resolved
     payoff's three stacked lines a little more room, alongside the
     is-resolved label fix below which was the main source of the actual
     crowding. */
  .story-result-center-ring {
    width: clamp(148px, 48vw, 230px);
    height: clamp(148px, 48vw, 230px);
  }

  .story-result-value {
    font-size: clamp(1.2rem, 4.2vw, 1.4rem);
  }

  .story-result-label {
    font-size: 0.54rem;
    letter-spacing: 0.02em;
    max-width: 96px;
    margin: 16px auto 0;
    /* 28px was sized assuming this always wraps to 2 lines — it doesn't:
       "Annual ecommerce revenue" wraps to 3 lines at this width (measured
       scrollHeight 36px), so that figure was clipping the third line
       ("revenue") outright, not just trimming excess whitespace. Bumped
       past the real measured height with a safety margin generous enough
       to also cover a future longer stat label, since the whole point of
       this rule is only to give the is-resolved pair below something to
       collapse to 0 — a bigger cap here has no visual effect on the
       resting state as long as it clears the real content height. */
    max-height: 46px;
  }

  .story-result-center.is-resolved .story-result-label {
    margin-top: 2px;
    max-height: 0;
  }

  .story-result-payoff {
    margin-top: 3px;
  }

  .story-result-payoff-lines {
    font-size: clamp(1.05rem, 5vw, 1.32rem);
  }

  .story-result-payoff-supporting {
    max-width: 130px;
    font-size: 0.54rem;
  }

  /* Only 3 widths in play at once (queue keeps 4 slots filled, one narrow
     enough to always fit two per row) — kept close to desktop's but
     trimmed so two neighbouring quadrant slots never touch. Trimmed a
     little further (was 112/120/128) alongside the smaller mobile ring
     and corner-pushed MOBILE_HOME_SLOTS above, as part of the same fix —
     smaller cards need less clearance to sit clear of the ring. */
  .bd-block[data-size="n"] .bd-block-card {
    width: clamp(88px, 28vw, 104px);
  }

  .bd-block[data-size="m"] .bd-block-card {
    width: clamp(94px, 30vw, 110px);
  }

  .bd-block[data-size="w"] .bd-block-card {
    width: clamp(100px, 32vw, 118px);
  }

  .bd-block-term {
    font-size: 0.82rem;
  }

  .bd-block-expl {
    font-size: 0.76rem;
    line-height: 1.25;
  }

  /* Snapped pieces compact further than desktop's — the explanation line
     already had its moment via the micro-copy reveal toast just before
     this, so hiding it here (rather than shrinking it further) is what
     keeps the gathered ring legible without tipping into the "tiny
     floating label" the brief explicitly ruled out. */
  .bd-block.is-snapped .bd-block-card {
    width: 104px !important;
    padding: 7px 8px 6px;
  }

  .bd-block.is-snapped .bd-block-term {
    font-size: 0.76rem;
    margin-bottom: 0;
  }

  .bd-block.is-snapped .bd-block-expl {
    display: none;
  }


  /* Homepage ending — dedicated mobile scale, not desktop shrunk down.
     Bridge lines and the final CTA's opening question/GOOD. share a
     smaller ceiling than desktop's (6.2rem/5rem -> ~2.6rem) so two words
     like "THERE'S MORE." or "WORTH BUILDING?" never come close to
     wrapping awkwardly at narrow widths; the payoff line keeps using
     --fs-hero-display (the same token the hero itself already relies on
     at every viewport, mobile included) rather than a bespoke scale. */
  .story-bridge-line { font-size: clamp(2rem, 9vw, 2.7rem); }

  /* Mobile per the brief: keep it simple, large easy tap target, no
     cursor/magnetic effects (cursor.js never initialises on touch, so
     there's nothing to disable here — this is just sizing). Same
     full-width/centred treatment as .story-final-btn's own mobile rule
     below, for the same reason: a comfortable one-thumb tap target. */
  .story-bridge-cta-wrap { width: 100%; }
  .story-bridge-cta {
    width: 100%;
    justify-content: center;
    padding: 18px clamp(24px, 6vw, 40px);
  }

  .story-final-line,
  .story-final-accent { font-size: clamp(1.8rem, 8vw, 2.6rem); }
  .story-final-payoff { margin-top: 4px; }
  .story-final-supporting { font-size: 0.92rem; }
  .story-final-btn {
    width: 100%;
    justify-content: center;
    padding: 18px clamp(24px, 6vw, 40px);
  }

  /* Mobile stacks FIND/IDENTIFY/BUY above a full-width diagram instead of
     sitting beside it (the desktop side-by-side row only works with a
     wide viewport). The diagram is sized by its own WIDTH here (not
     height, like desktop) so it's always shown at full width, in full,
     directly under the copy — never cropped, shrunk into a corner, or
     wrapped in a dark container. Because a full-width diagram plus copy
     can occasionally run taller than one screen, .story-stage below
     switches from a hard-clipped 100vh box to an auto-height one with a
     100vh floor, so content can never be clipped away — worst case it
     settles into normal flow a little before the chapter's scroll range
     ends, rather than ever being hidden. */
  .story-difference .story-stage {
    height: auto;
    min-height: 100vh;
    padding-top: calc(var(--header-height) + clamp(6px, 1vh, 12px));
    padding-bottom: clamp(10px, 2vh, 18px);
  }

  .ipl-demo {
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }

  .ipl-copy {
    flex: 0 0 auto;
    justify-content: flex-start;
  }

  /* The copy column is deliberately compact — smaller than a naive
     mobile scale-down of the desktop sizes — specifically so the camera
     viewport below it (see .ipl-mobile-viewport) can stay as large as
     possible AND the bottom sheet anchored to it can stay within view
     once the sticky stage is holding this section on screen. Every size
     below was tuned against a real on-device measurement of the combined
     stack, not guessed.

     Margins below were opened up slightly from an earlier, tighter pass
     (6/4/5px) for real separation between each element — previously the
     whole block read as one dense clump. That's affordable without
     pushing the diagram further down the page because the headline itself
     (data.difference.subhead, config.js) is now two short declarative
     lines instead of one long sentence that used to wrap across 3-4 lines
     at this width — fewer, better-spaced lines nets out to less total
     height before the diagram, not more. */
  .story-difference .story-index { margin-bottom: 8px; }
  .story-kicker { font-size: 0.72rem; margin-bottom: 8px; }
  .story-difference .story-headline { font-size: clamp(1.05rem, 4.6vw, 1.35rem); line-height: 1.08; margin-bottom: 10px; }
  .story-explain { font-size: 0.7rem; line-height: 1.32; max-width: 100%; }

  /* Kept as the same vertical rail as desktop (not collapsed away, not
     hiding the description) — this is meant to preserve the desktop
     experience, just at a more compact scale. Index + label + description
     now share a single line (an em dash separator, added below, stands in
     for the desktop version's own line break) rather than the description
     wrapping to a second line under each step — recovers a further
     line-height per step for the camera viewport below without hiding any
     of the copy. */
  .ipl-steps {
    gap: 6px;
    padding-left: 16px;
    margin-top: 10px;
  }

  .ipl-step {
    flex-direction: row;
    flex-wrap: wrap;
    align-items: baseline;
    column-gap: 6px;
    row-gap: 1px;
  }

  .ipl-step-label { font-size: 0.9rem; }
  .ipl-step-desc {
    font-size: 0.64rem;
    line-height: 1.25;
    max-width: 40ch;
  }
  .ipl-step-desc::before { content: '\00B7\0020'; } /* middle dot + space, replaces the old em dash separator */

  /* The desktop diagram+card is hidden entirely on mobile — see
     .ipl-mobile-scene below for the dedicated camera sequence built on
     its own, independent copy of the same real image/hotspots, rather
     than this desktop layout shrunk to fit (which is what a plain
     `width: 80%` here used to do, and read as a small diagram floating
     in empty space with none of the desktop version's impact). */
  .ipl-diagram-wrap {
    display: none;
  }

  .ipl-mobile-scene {
    display: block;
    flex: 0 0 auto;
  }

  /* The "camera" frame: a fixed-size crop window, not a box that shrinks
     to contain the whole diagram. The height below keeps it as large as
     the screen genuinely allows while guaranteeing the image (which at
     its own aspect ratio is taller than it is wide by a factor of ~1.31)
     can always fully cover it at every zoom level used by the camera —
     see applyMobileCamera() in mowermerch-story.js — so panning never
     reveals empty cream background past the diagram's own edges. */
  .ipl-mobile-viewport {
    position: relative;
    width: 100%;
    /* The `vw` term is a deliberately conservative stand-in for "this
       column's own width" (CSS has no container-relative unit reliable
       enough here) — it's capped well under the image's real ~1.31:1
       height:width ratio (108% vs. ~131%) so the image can always fully
       cover this box even though the column itself is narrower than the
       viewport (edge-gutter padding on both sides) and the margin has to
       absorb that gap across every device size, not just the ones this
       was tested against. Raised from min(58vh, 104vw) now that the much
       shorter copy (see .story-explain/.ipl-steps content) frees up real
       vertical room — the diagram should be the dominant thing on the
       screen, not sized to whatever's left over. */
    height: min(66vh, 116vw);
    overflow: hidden;
  }

  /* This box's own (untransformed) size at width:100% *is* the FIND
     overview frame — "the real diagram at almost full viewport width",
     not a smaller image with room to spare. Panning/zooming is a plain
     translate()+scale() on top, computed fresh every scroll tick in JS;
     aspect-ratio (matching the source image's real 2034x2667 pixels)
     means the browser derives the correct height from that width with
     zero JS layout math needed here. */
  .ipl-mobile-imgbox {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    aspect-ratio: 2034 / 2667;
    transform-origin: 0 0;
    will-change: transform;
  }

  .ipl-mobile-img {
    display: block;
    width: 100%;
    height: 100%;
  }

  .ipl-mobile-hotspots {
    position: absolute;
    inset: 0;
  }

  /* Reuses .ipl-hotspot's ring/pulse styling as-is (defined above, for
     the desktop diagram) — a fixed px-sized marker that scales visually
     with .ipl-mobile-imgbox's own transform, so it zooms with the image
     exactly like a real printed callout would. Non-interactive here
     (the mobile sequence is scroll-driven, not tap-driven). */
  .ipl-mobile-hotspots .ipl-hotspot {
    pointer-events: none;
    cursor: default;
  }

  /* A small floating tag connecting the active hotspot's printed
     reference number to its name during IDENTIFY/BUY — positioned in JS
     directly above wherever that hotspot currently renders on screen
     (which the camera always keeps at a known point), not a full card
     duplicating what the bottom sheet already shows.

     `position: fixed` (was `absolute`) — the previous version was an
     absolutely-positioned child of .ipl-mobile-viewport, which is
     deliberately overflow:hidden (needed to crop the pan/zoom camera
     image). A long part name centred on a hotspot near either edge —
     e.g. "Spindle Housing Assembly" on Ref. 51, which sits close to the
     right edge — got hard-clipped by that same boundary mid-word ("REF.
     51 Spindle Housing Ass..."), never wrapping. `position: fixed` lets
     the chip escape that clipping box entirely (its containing block
     becomes the nearest transformed ancestor — .ipl-demo, via
     will-change: transform — which has no overflow clipping of its own;
     see the offsetParent-based math in applyMobileCamera() in
     mowermerch-story.js, which stays correct either way). left/top are
     now set every frame in JS as real, edge-clamped screen coordinates
     (min 16px clearance, flips to open left/right/above/below as
     needed) instead of this fixed transform/margin pairing — text wraps
     naturally within max-width below rather than running off unclipped. */
  .ipl-mobile-chip {
    position: fixed;
    left: 0;
    top: 0;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    max-width: calc(100vw - 32px);
    background: var(--color-ink);
    color: var(--color-bone);
    padding: 6px 10px;
    white-space: normal;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s var(--ease-editorial);
    z-index: 3;
  }

  .ipl-mobile-chip.is-visible {
    opacity: 1;
  }

  .ipl-mobile-chip-ref {
    font-family: var(--font-body);
    font-weight: 700;
    font-size: 0.6rem;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: var(--ipl-accent);
  }

  .ipl-mobile-chip-name {
    font-family: var(--font-body);
    font-size: 0.66rem;
    font-weight: 600;
  }

  /* The BUY bottom sheet — layered over the *lower* portion of the same
     viewport the diagram fills (not pushed below it), so the zoomed
     diagram stays visible behind it, per the brief. Real ecommerce UI
     (ref/name/OEM/price/qty/CTA — reuses the desktop card's own
     .ipl-card-* classes/typography, see below) sliding up from off-
     screen rather than a big card dropped in the middle of the diagram. */
  .ipl-mobile-sheet {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: clamp(180px, 36%, 248px);
    background: var(--color-bone);
    padding: 14px 16px 15px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    transform: translateY(105%);
    transition: transform 0.45s var(--ease-editorial);
    box-shadow: 0 -14px 30px rgba(17, 17, 17, 0.14);
    z-index: 4;
  }

  .ipl-mobile-sheet.is-open {
    transform: translateY(0);
  }

  /* Own compact type scale for the shared .ipl-card-* classes (same
     bare selectors the desktop card uses — reused here, not
     redeclared, so REF/name/OEM/price/CTA styling stays one source of
     truth) — tuned for the sheet's own smaller footprint rather than
     inherited desktop sizes. */
  .ipl-card-top { margin-bottom: 4px; }
  .ipl-card-name { font-size: 0.92rem; margin-bottom: 2px; }
  .ipl-card-oem { margin-bottom: 7px; }
  .ipl-card-row { margin-bottom: 7px; }
  .ipl-card-price { font-size: 0.92rem; }
  .ipl-card-cta { padding: 8px 8px; }
  .ipl-card-demo-note { margin-top: 5px; }
}

/* Shorter phones (older/compact devices — e.g. iPhone SE-class, ~<750px
   tall) can't fit the full stack in one pinned screen at the sizes above
   no matter how much text shrinks — the diagram's own height is driven
   by viewport *width*, not height, so a narrow-but-short screen still
   gets a tall camera frame against a much smaller vertical budget.
   Rather than let the bottom sheet trail further off the bottom, this
   tier shrinks the camera viewport further and compacts the copy more
   aggressively — still never hard-clipped either way. */
@media (max-width: 900px) and (max-height: 750px) {
  .ipl-mobile-viewport { height: min(54vh, 108vw); }

  .story-explain { font-size: 0.68rem; line-height: 1.3; }

  .ipl-steps { gap: 4px; margin-top: 6px; }
  .ipl-step-desc { font-size: 0.6rem; line-height: 1.2; }

  .ipl-mobile-sheet { height: clamp(160px, 32%, 210px); padding: 11px 14px 12px; }
  .ipl-card-name { font-size: 0.84rem; }
  .ipl-card-price { font-size: 0.84rem; }

  /* Result's board-stage now fills leftover space via flex:1 (see the
     long comment on .story-result .story-stage in the 900px tier above)
     rather than a fixed height, so it can no longer outrun a short
     viewport the way it used to — nothing to override here for that.
     Still worth compacting the intro copy a little further on these
     shorter phones purely so the board itself gets a bigger share of an
     already-tight vertical budget. */
  .story-result .story-stage { gap: clamp(8px, 1vh, 12px); }
  .story-result-board-stage { max-height: 460px; }
  .story-result-eyebrow { margin-bottom: 6px; }
  .story-result-heading { font-size: 1.6rem; margin-bottom: 6px; }
  .story-result-intro-supporting { font-size: 0.86rem; }
}
