/* ============================================================
   Matey v2 — OPT-1 landing page
   Externalized stylesheet (cache-bust: ?v=1)
   Token set ported from the prior inline :root.
   ============================================================ */

/* SHOP-v2 ≤430 carousel track offset — driven per-frame by shopMobileCarouselV2.
   Registered non-inheriting so the per-frame write updates ONLY the track's own
   transform and never invalidates the 9-card subtree's custom-property resolution.
   The eased-velocity curve is unchanged; this only hoists the translate3d()
   function out of the rAF loop so the loop writes a bare <length>, not a string
   built each tick. Fallback 49px in the consumer covers pre-registration. */
@property --shop-tx {
  syntax: "<length>";
  inherits: false;
  initial-value: 49px;
}

:root {
  /* ── Colors (from Figma / prior inline token set) ── */
  --color-bg:           #f9f9f9;
  --color-green-light:  #e6f8e9;
  --color-green-card:   #d3f7d8;
  --color-green-accent: #aef5b7;
  --color-green-dark:   #2d690d;
  --color-green-deep:   #42604d;   /* dark green — was a raw literal (~10 uses); tokenized here for the JOIN card */
  --color-black:        #1e2e29;
  --color-text:         #252423;
  --color-white:        #ffffff;

  /* ── Fonts (dev substitutes; production families listed in index.html) ── */
  --font-display: 'ABC Ginto Nord Condensed', 'Barlow Condensed', sans-serif;
  --font-ui:      'ABC Ginto Normal', 'Barlow', sans-serif;
  --font-body:    'Nunito', sans-serif;
  --font-btn:     'Roboto', sans-serif;
  --font-noto:    'Noto Sans', sans-serif;   /* chat panel (Figma uses Noto Sans) */
  --font-bricolage: 'Bricolage Grotesque', sans-serif;   /* SCAN chip label "Choose it." (Figma 1999:779) */

  /* ── Spacing (8px base unit) ── */
  --sp-1:  4px;
  --sp-2:  8px;
  --sp-3:  10px;
  --sp-4:  16px;
  --sp-5:  20px;
  --sp-6:  24px;
  --sp-7:  48px;
  --sp-8:  64px;
  --sp-9: 128px;

  /* ── Grid (OPT-1: 1440 frame, 120px side margins, 1200px content) ── */
  --grid-margin:  120px;
  --grid-content: 1200px;
}

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

body {
  font-family: var(--font-body);
  color: var(--color-text);
  background: var(--color-bg);
}

.page-wrapper { overflow-x: hidden; }

/* Shared content container — 1200px centered, 120px gutters at the 1440 base */
.opt-container {
  width: min(var(--grid-content), calc(100% - (var(--grid-margin) * 2)));
  margin-inline: auto;
}

/* ════════════════════════════════════════
   HEADER  (Figma 1148:4 — h80, px120 py8)
════════════════════════════════════════ */
/* ══ STICKY "HOW IT WORKS" BAR (1988:1058) ══════════════════════════════════════════════════════════
   1440x94 #42604D band: 34x35 mark, a 1200 row holding the title and the three items, then a 40px
   burger. The active indicator is a 136x3.46 #FF3567 bar at the bottom edge that slides between the
   three item positions (0 / 307 / 614 within the 819 group — an even 307 pitch, so it is a fixed-width
   bar with three stops). GSAP moves it, not a CSS transition, matching the Feed-expand rebuild.
   RANGE: shown over MEASURE YOUR SPACE and SKILLS, hidden over the hero and PRICING/JOIN. */
/* ONE BAND HEIGHT, TWO READERS. The bar draws itself at --hiw-band and the mobile sections reserve the
   same token, so the clearance cannot drift from the thing it is clearing. (Desktop's 94 is still a
   literal in .opt-scan's padding-top and in the JS --scan-scale ratio — those predate this token and are
   left alone deliberately; they carry the SAME 94, so nothing moved.) */
/* ONE EDGE FOR THE WHOLE PAGE CHROME. The sticky bar and the hero header are the only two full-width
   chromes, and they sat on different references: the bar ramped 24 -> 16 while the header held the node's
   flat 120, so the wordmark sat 96 inside the bar's mark at 1440 and 104 at 768 — the gap GREW as the
   viewport narrowed. Both read this now, so they cannot drift. The clamp floors at 16 by 768, which is the
   value <=430 already used, so the condensed state needs no override of its own. */
:root { --edge-inset: clamp(16px, calc(24px - (1440px - 100vw) * 0.011905), 24px); }
:root { --hiw-band: 94px; }
/* RE-DERIVED FOR ONE LINE. 64 was cut for the item's two lines (22 + 24 + 8/8 padding). With the
   descriptions gone the tallest thing in the line is no longer the item at 18.3 — it is the MARK at
   35.14, so the band is mark + 8 top + 8 bottom = 51.14, taken to 52. Everything that reserves the bar
   reads this token, so the mobile sections follow it down without a second edit. */
@media (max-width: 767px) { :root { --hiw-band: 52px; } }
.opt-hiw {
  position: fixed; top: 0; left: 0; right: 0; z-index: 40;
  height: var(--hiw-band);
  /* NO FIXED OUTER GAP. 24 + 34.24 mark + 64 + 1200 row + 64 + 40 burger + 24 = 1450.24 — the band is
     1440, so the 10.24 overflowed off the RIGHT and ate that side's padding: 24 left, 13.76 right. The
     three children are all node-exact widths, so the gap is the one free quantity: space-between derives
     it from what is actually left over (117.76 / 2 = 58.88) instead of guessing 64, and both paddings
     read 24. The row grows to its 1200 max-width first, so the leftover is the same on either side. */
  display: flex; align-items: center; justify-content: space-between;
  /* RAMP, NOT RUNGS. Everything below 1440 is one continuous ratchet down to 768 rather than a stack of
     per-tier constants: each ramped property is clamp(floor, 1440-value minus (1440 - viewport) x slope,
     1440-value), so 1440 keeps its shipped number, 768 lands on the floor, and every width between gets a
     proportional value with no cliff to resize across. Above 1440 the subtraction goes negative and clamp
     caps at the 1440 value, so xLg is untouched. Padding is --edge-inset, the page-chrome edge the hero
     header shares — same ramp, 24 at 1440 -> 16 at 768, now stated once. */
  padding: var(--edge-inset);
  background: #42604d;
  opacity: 0; visibility: hidden; pointer-events: none;
}
.opt-hiw.is-shown { opacity: 1; visibility: visible; pointer-events: auto; }
/* An <a> now — the mark jumps back to the top of the deck. display:block so the box is the link's own
   (an inline anchor would size to the line box and leave dead pixels around the glyph).
   NO RING ON CLICK, RING ON KEYBOARD. What read as a "stroke" appearing when the mark became a link is the
   UA focus ring: an <a> takes focus on mouse-down, and Safari/Firefox paint the ring for pointer focus too
   (Chrome does not). :focus-visible is the split — the ring is suppressed only for the pointer case and
   still drawn for keyboard, so this is not `outline: none`, which would strand keyboard users on an
   invisible target. -webkit-tap-highlight-color kills the grey flash iOS paints on tap, which is the same
   artefact by another route. */
.opt-hiw__mark { flex: 0 0 auto; width: 34.24px; height: 35.14px; display: block; cursor: pointer;
  -webkit-tap-highlight-color: transparent; }
.opt-hiw__mark:focus:not(:focus-visible) { outline: none; }
.opt-hiw__mark:focus-visible { outline: 2px solid #aef5b7; outline-offset: 3px; border-radius: 2px; }
.opt-hiw__mark img { width: 100%; height: 100%; display: block; }
/* THE ROW IS A WRAPPER, NOT A BOX. display:contents lifts the title and the items group out of it and
   into .opt-hiw's own flex line, so that line runs mark / title / items / burger — four blocks, one
   space-between, three gaps that are equal BY CONSTRUCTION rather than by three numbers kept in sync.
   (.opt-hiw__bar is absolutely positioned, so flex skips it.) The node's own 323/819/58 are gone with it:
   a fixed box wider than its text pushes the slack into the gap beside it, which is exactly what made the
   three gaps read 60 / 59 / 70 when the box gaps were 58.88 / 58 / 58.88. The blocks hug their ink now
   and the leftover splits three ways — 63 each at 1440. */
.opt-hiw__row { display: contents; }
.opt-hiw__title {
  flex: 0 0 auto;
  font-family: var(--font-bricolage); font-weight: 800;
  font-variation-settings: 'opsz' 96, 'wdth' 100;
  /* THE TITLE IS THE BIGGEST LEVER — 320.76 of the 1251 floor, and the only block that can give up real
     width without losing a word. 48 at 1440 -> 28 at 768 (20 / 672 per px) takes it to ~187, which is what
     buys the three items their room at 768. line-height goes unitless so it ramps with the size instead of
     holding the 46px cut for 48. */
  font-size: clamp(28px, calc(48px - (1440px - 100vw) * 0.029762), 48px);
  line-height: 0.96; color: #aef5b7;
}
/* 1988:1063 — items at 0 / 307 / 614: two 136 boxes on a 171 gap, so the 307 pitch still comes from the
   boxes, not from a group width. The GROUP hugs (was a fixed 819) — the node's 819 ran 11px past MAKE IT.'s
   longest line, and that overhang landed in the gap before the burger, reading as 70 against the other
   two 60/59. */
/* The 171 gap is the second lever and the fattest single quantity in the bar (2 x 171 = 342). 171 at 1440
   -> 32 at 768 (139 / 672 per px). The 307 pitch is a 1440 fact and it goes with the gap — below 1440 the
   items are read as three blocks that need to fit, not as a node's grid. */
.opt-hiw__items { flex: 0 0 auto; display: flex;
  gap: clamp(32px, calc(171px - (1440px - 100vw) * 0.206845), 171px); }
.opt-hiw__item {
  flex: 0 0 136px; display: flex; flex-direction: column;
  text-decoration: none; cursor: pointer;
}
/* Hugs, so the group ends ON "Watch our tutorials" instead of 11px past the node's 205 box. stickyHeader()
   measures the indicator off this rect, so the pink bar follows the narrower box on its own. */
.opt-hiw__item:last-child { flex: 0 0 auto; }
/* Below 1440 the 136 boxes hug too. The 307 pitch they exist to reproduce is a 1440 fact that the gap ramp
   has already left behind, and a 136 box holding 111 of ink at 768 is 25px the bar cannot spare. One step
   at 1439, not a ramp: box-sizing has no in-between worth having. */
@media (max-width: 1439px) { .opt-hiw__item { flex: 0 0 auto; } }
.opt-hiw__item-h {
  font-family: var(--font-bricolage); font-weight: 800;
  font-variation-settings: 'opsz' 96, 'wdth' 100;
  /* 18 at 1440 -> 15 at 768 (3 / 672 per px), heading and description together so the two lines stay a
     pair. 15 is the floor because below it the descriptions stop reading as labels. */
  font-size: clamp(15px, calc(18px - (1440px - 100vw) * 0.004464), 18px);
  line-height: 1.22; color: #aef5b7;
}
.opt-hiw__item-d {
  font-family: var(--font-ui); font-weight: 400;
  font-size: clamp(15px, calc(18px - (1440px - 100vw) * 0.004464), 18px);
  line-height: 1.33; color: #d3f7d8;
}
/* margin-left:auto is GONE — it swallowed every pixel of free space, which under space-between would
   collapse the mark-to-title gap to 0. The parent hands out the leftover evenly now. */
/* ONE RAMPED QUANTITY, THREE DERIVED ONES. --hiw-burger is the box; the bar thickness (3/40), the gap
   between bars (6/40) and the radius (2/40) are ratios OF it, so the glyph scales as a glyph — it never
   reads as a smaller box still holding 3px bars. 40 at 1440 -> 30 at the 768 floor (10 / 672 per px), on
   the same ramp as the title, the item gap, the item type and the padding; 20 in the condensed bar, which
   is the same halving the band takes (94 -> 64 is the frame, 40 -> 20 the glyph inside it). */
.opt-hiw { --hiw-burger: clamp(30px, calc(40px - (1440px - 100vw) * 0.014881), 40px); }
.opt-hiw__burger { flex: 0 0 var(--hiw-burger); height: var(--hiw-burger); display: flex;
  flex-direction: column; justify-content: center; gap: calc(var(--hiw-burger) * 0.15); }
.opt-hiw__burger i { display: block; height: calc(var(--hiw-burger) * 0.075);
  border-radius: calc(var(--hiw-burger) * 0.05); background: #aef5b7; }
/* 2088:3286 — 136 x 3.46 at the band's bottom edge. GSAP owns BOTH its x and its width: the items are
   different widths (136 / 136 / 205), so 136 is only the pre-JS default — the node's own value, which
   happens to be CHOOSE IT.'s — and stickyHeader() measures the active item from the first placement on. */
.opt-hiw__bar {
  position: absolute; bottom: 0; left: 0;
  width: 136px; height: 3.46px; background: #ff3567;
  opacity: 0;
}

/* ══ CONDENSED BAR (<=767) ══════════════════════════════════════════════════════════════════════════
   THE RAMP RUNS OUT HERE, IT DOESN'T FAIL HERE. Three items plus a title cannot fit under ~730px at any
   type size worth reading — the floor of the ramped tier, measured — so 767 is where the bar changes what
   it SHOWS rather than how big it draws it: mark, the ONE selected item, burger. The title goes (the mark
   already says whose bar this is) and the two unselected items go with it, which is the whole 430-1251
   band this closes. .opt-hiw keeps its space-between, so the mark and the burger stay pinned to their own
   padding edges and the selected item takes the middle with equal air either side — the same rule that
   places the four blocks at 1440, just with two fewer blocks in the line.
   The item is selected by stickyHeader(), which already knows the index: it now stamps .is-active on that
   <a> as it moves the indicator, so CSS never has to work out which card is showing.
   The pink indicator is hidden: with one item on screen it underlines the only thing there.
   The band drops 94 -> 52 because it is FIXED over the content — 94px of overlay on a phone is a third of
   a small viewport. 52 is re-derived for one line: with the descriptions dropped the tallest thing in the
   line is the 35.14 mark, so 35.14 + 8 + 8. Every section reservation and --scan-scale read this token. DEVICE-VERIFY: <=430 has never rendered
   this bar at all (the display:none it used to carry is gone), so the overlap against SCAN and SKILLS is
   unproven in the emulator — check it on hardware before trusting it. */
@media (max-width: 767px) {
  .opt-hiw { padding: 8px var(--edge-inset); --hiw-burger: 20px; }   /* height from --hiw-band, sides from the shared edge (16 here) */
  .opt-hiw__title { display: none; }
  /* ALL THREE ITEMS, PRIMARY TITLES ONLY. The descriptions are what the width cannot afford, not the
     items — dropping the two unselected items loses the nav; dropping three description lines keeps it.
     The indicator comes back with them: three titles need something saying which one you are on, and
     stickyHeader() already measures it off the active item's rect, so it lands without a number here. */
  .opt-hiw__item-d { display: none; }
  .opt-hiw__items { gap: clamp(8px, 4.2vw, 24px); }
}

.opt-header {
  background: var(--color-green-light);
  padding: var(--sp-2) 0;
}

.opt-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 64px;
}

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

.opt-logo__mark {
  height: 41px;
  width: auto;
  display: block;
}

.opt-logo__tagline {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 32px;
  line-height: normal;
  color: var(--color-green-dark);
  white-space: nowrap;
  padding: var(--sp-3);
}

.opt-nav {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}

.opt-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-2);
  border-radius: 8px;
  border: 1px solid transparent;
  background: transparent;
  font-family: var(--font-ui);
  font-weight: 300;
  font-size: 16px;
  line-height: 24px;
  color: var(--color-black);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}

/* Join — filled #aef5b7 pill; links to #join section (label reverted "Notify" → "Join" at v=146) */
.opt-btn--join {
  background: var(--color-green-accent);
}

.opt-btn--join:hover { background: #9aeea4; }

/* ── Section step-chip (SCAN / PLAN / MAKE) — Figma 1527:449 / 1529:145 / 1529:130 ──
   97.66px rounded-square: dark-green fill + mint border; icon-disc above a Noto Sans label.
   SCAN/PLAN wrap a small glyph in a translucent disc; MAKE's disc is baked into its svg. */
.opt-chip {
  flex-shrink: 0;
  box-sizing: border-box;
  width: 97.66px;
  height: 97.66px;
  background: #42604d;
  border: 1px solid var(--color-green-accent);
  border-radius: 31.25px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.opt-chip__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
}
.opt-chip__icon {
  width: 27.34px;
  height: 27.34px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
}
.opt-chip__icon img { display: block; }
/* NOTE: --scan is deliberately NOT in this group. rgba(174,245,183,0.25) over the chip's #42604d composites
   to EXACTLY #5D8568 — the same value three of the four choose-it bars are filled with — so they'd vanish
   against the disc. The asset draws them straight on the #42604D field; SCAN now does too. */
.opt-chip--shop .opt-chip__icon,
.opt-chip--plan .opt-chip__icon { background: rgba(174, 245, 183, 0.25); }
/* SCAN's glyph (icon-choose-it.svg, 33x33, bars only) sits DIRECTLY on the chip field, no disc. Sized from
   the SOURCE ratio rather than a picked px: the chip-*.svg exports draw this glyph 33 wide in a 98 chip,
   and .opt-chip is 97.66 -> 33 x 97.66/98. The icon box IS the glyph, so the shared 27.34 box is overridden
   here. --scan-scoped; PLAN/SHOP/MAKE keep their disc, box and sizes. */
.opt-chip--scan .opt-chip__icon { width: 32.885px; height: 32.885px; border-radius: 0; }
.opt-chip--scan .opt-chip__icon img { width: 32.885px; height: 32.885px; }
.opt-chip--plan .opt-chip__icon img { width: 12.24px; height: 11.39px; }
.opt-chip--shop .opt-chip__icon img { width: 16.5px;  height: 14px;    }   /* shopping cart (SHOP states) — cart viewBox 35×30 */
.opt-chip--make .opt-chip__icon img { width: 27.34px; height: 27.34px; }
/* LEARN chip (2052:158) — derived the same way as --scan: icon-learn-it.svg draws the glyph 33x36 inside
   chip-learn-it.svg's 98 chip, and .opt-chip is 97.66, so the box is 33/36 x 97.66/98. Ratio off the
   SOURCE, not a picked px, so the glyph keeps its aspect if the chip is ever resized. The icon IS the
   glyph here (no disc), so the shared 27.34 box and the circle are both overridden. */
.opt-chip--learn .opt-chip__icon { width: 32.885px; height: 35.878px; border-radius: 0; }
.opt-chip--learn .opt-chip__icon img { width: 32.885px; height: 35.878px; }
/* Desktop SHOP state-2 chip only: play-only glyph on the shared CSS circle at cart size, so the play reads
   at the same visual weight as the sibling icons (cart / bars / heart / control-bar play). icon-chip-make.svg
   bakes a small triangle into its own circle → undersized. SKILLS + ≤430 header keep icon-chip-make.svg. */
.opt-shop__card--pb .opt-chip--make .opt-chip__icon { background: rgba(174, 245, 183, 0.25); }
.opt-shop__card--pb .opt-chip--make .opt-chip__icon img { width: 16.5px; height: 14px; }
/* ONE label face for every SECTION chip (scan/learn/shop/make) — no per-section treatment. The panel chip
   .opt-chip--shoplist is a ruled exception and keeps its own rule; see the note at that selector.
   Resolved by rasterising the three chip
   exports' outlined labels against both candidates at the shipped 15.625px:
       label         export ink        Bricolage 800      Noto Sans 300
       Choose it.    68.68 x 11.45     68.75 x 11.50      68.00 x 12.25
       Learn it.     54.59 x 11.45     54.75 x 11.50      54.75 x 11.75
       Make it.      53.41 x 11.45     53.50 x 11.50      52.50 x 12.25
   Bricolage ExtraBold matches within 0.1px on width and 0.05px on height across all three, and holds the
   exports' CONSTANT 11.45 ink height; Noto's height varies per string (11.75-12.25). So the SCAN chip's
   override was right and this base rule was wrong — Bricolage is promoted here and the override deleted,
   which also corrects PLAN/SHOP/MAKE, all of which were rendering in the wrong face. */
.opt-chip__label {
  font-family: var(--font-bricolage);
  font-weight: 800;                                 /* ExtraBold */
  font-variation-settings: 'opsz' 96, 'wdth' 100;   /* Figma variation axes */
  font-size: 15.625px;
  line-height: 23.438px;
  color: #e6f8e9;
}
/* JOIN chip (node 1531:383) — same .opt-chip box, but the Matey logo-mark inside instead of glyph+label */
.opt-chip--join { align-self: center; }   /* centre the chip in the (flex-start) JOIN content column */
.opt-chip__logo { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; }
.opt-chip__logo img { width: 34.245px; height: 35.142px; display: block; }

.opt-btn--about {
  border-color: var(--color-black);
}

.opt-btn--about:hover { background: rgba(30, 46, 41, 0.06); }

/* ════════════════════════════════════════
   HERO  (Figma 1148:19 — h810, bg IMG-vision)
════════════════════════════════════════ */
.opt-hero {
  position: relative;
  height: 810px;
  overflow: hidden;
}

.opt-hero__bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  z-index: 0;
}

.opt-hero__inner {
  position: relative;
  z-index: 2;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;   /* node 1528:105 — single centered column (was space-between two-column) */
}

/* ── Centered column: headline + animated prompt box (node 1528:105) ── */
.opt-hero__col {
  width: 700px;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 16px;                 /* node 1528 — headline ↔ prompt box */
}

.opt-hero__headline {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

.opt-hero__headline span {
  display: block;
  font-family: var(--font-body);
  font-size: 58px;
  line-height: 68px;
  white-space: nowrap;
}

.opt-hero__headline .l1 { font-weight: 500; color: var(--color-green-card); }
.opt-hero__headline .l2 { font-weight: 400; color: var(--color-white); }

/* hero prompt box — node 1528:114 "Text-animation-1": #ebfded pill, logo-mark + a looping
   Nunito Regular 20px line. 5 strings: previous line retracts right→left, then the next line
   types in one character at a time; driven by heroPrompt() in main.js. */
.opt-hero__prompt {
  box-sizing: border-box;
  width: 700px;
  height: 100px;
  padding: 16px;
  background: #ebfded;
  border-radius: 8px;
  display: flex;
  align-items: center;
  gap: 10px;
}
.opt-hero__prompt-mark {
  flex-shrink: 0;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.opt-hero__prompt-mark img { width: 34.245px; height: 35.142px; display: block; }
.opt-hero__prompt-textwrap {
  flex: 1;
  min-width: 0;
  overflow: hidden;          /* clip the line as it slides out/in */
}
.opt-hero__prompt-text {
  display: inline-block;
  font-family: var(--font-body);
  font-weight: 400;          /* Nunito Regular 20px (was Medium 500); keep 20px — do NOT regress to 24px */
  font-size: 20px;
  line-height: 24px;
  color: #1e2e29;
  white-space: nowrap;
  transition: transform 0.45s ease, opacity 0.45s ease;
}
.opt-hero__prompt-text.is-instant { transition: none; }       /* snap back to rest before typing (no tween) */
.opt-hero__prompt-text.is-out { transform: translateX(-100%); opacity: 0; }   /* retract right→left */

/* ── DEAD CSS (cleanup pass): .opt-chat* below is orphaned — the hero chat panel was removed
      at v=147 per node 1164:885. Kept temporarily; safe to delete wholesale. ──
   ── Right column: chat panel (1162:376) ── */
.opt-chat {
  position: relative;
  width: 368px;
  height: 591px;
  padding: 16px;
  background: #42604d;
  border-radius: 24px;
}

.opt-chat__body {
  width: 336px;
  display: flex;
  flex-direction: column;
  gap: 48px;
}

/* intro */
.opt-chat__intro { display: flex; flex-direction: column; gap: 10px; }
.opt-chat__intro-mark { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; }
.opt-chat__intro-mark img { width: 34.245px; height: 35.142px; display: block; }
.opt-chat__intro-text { padding-left: 4px; }
.opt-chat__intro-text p {
  font-family: var(--font-noto);
  font-weight: 500;
  font-size: 16px;
  line-height: normal;
  color: var(--color-green-light);
}

/* bubbles */
.opt-chat__bubbles {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding-left: 4px;
  width: 100%;
}

.opt-chat__bubble {
  width: fit-content;
  max-width: 100%;
  height: 40px;
  padding: 4px 16px;
  display: flex;
  align-items: center;
  overflow: hidden;
  font-family: var(--font-noto);
  font-weight: 300;
  font-size: 12px;
  line-height: 24px;
  color: #000;
}

.opt-chat__bubble--in {
  align-self: flex-start;
  background: var(--color-green-accent);
  border-radius: 2px 16px 16px 16px;
}

.opt-chat__bubble--out {
  align-self: flex-end;
  margin-right: 48px;
  background: var(--color-white);
  border-radius: 16px 2px 16px 16px;
}

/* action cards */
.opt-chat__actions {
  display: flex;
  gap: 16px;
  align-items: center;
  padding-left: 4px;
  width: 100%;
}

.opt-chat__action {
  width: 97.656px;
  height: 97.656px;
  padding: 0.648px;
  background: #42604d;
  border: 0.977px solid var(--color-green-accent);
  border-radius: 31.25px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.opt-chat__action-inner {
  flex: 1;
  min-width: 0;
  padding-left: 11.719px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  align-items: flex-start;
}

.opt-chat__action-badge {
  flex-shrink: 0;
  width: 27.347px;
  height: 27.344px;
  background: rgba(174, 245, 183, 0.25);
  border-radius: 31.25px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.opt-chat__action-badge img { display: block; }
.opt-chat__action--scan        .opt-chat__action-badge img { width: 9.127px;  height: 9.126px; }
.opt-chat__action--inspiration .opt-chat__action-badge img { width: 13.183px; height: 9.983px; }
.opt-chat__action--plan img    { width: 27.347px; height: 27.344px; display: block; }

.opt-chat__action-label {
  font-family: var(--font-noto);
  font-weight: 300;
  font-size: 15.625px;
  line-height: 23.438px;
  color: var(--color-green-light);
}

/* footer prompt */
.opt-chat__footer {
  position: absolute;
  left: 16px;
  bottom: 16.07px;
  width: 336px;
  display: flex;
  align-items: center;
  gap: 10px;
}

.opt-chat__ask-icon {
  position: relative;
  flex-shrink: 0;
  width: 23.473px;
  height: 24.559px;
}

.opt-chat__ask-icon .base { position: absolute; inset: 0; width: 100%; height: 100%; }
.opt-chat__ask-icon .overlay { position: absolute; left: 5.59px; top: 6.24px; width: 12.292px; height: 12.07px; }

.opt-chat__ask-text {
  font-family: var(--font-noto);
  font-weight: 400;
  font-size: 12px;
  line-height: 24px;
  color: var(--color-green-light);
  white-space: nowrap;
}

/* ════════════════════════════════════════
   SCAN — picture-in-picture (Figma OPT-2 1164:998)
   Full-bleed room video, blurred, behind a sharp iPhone (1164:1001, 368×591)
   playing the same source with app chrome over it. No measurements overlay.
════════════════════════════════════════ */
.opt-scan {
  position: relative;
  min-height: 100vh;
  background: #42604d;
  display: flex;
  /* BOTTOM-ALIGNED, not centred, so the aperture clears the sticky bar. Centred put the phone's top at
     47 with the bar occupying 0-94, i.e. 47px of it underneath. The expanded phone is 56vw capped 90vh
     (806 at 1440x900), so reserving the bar's 94 at the top and anchoring to the bottom lands it exactly
     94..900. box-sizing is border-box globally, so the padding comes out of the 100vh rather than adding
     to it. --card-scale does NOT apply to this card, so 94 is a literal, matching the fixed bar. */
  /* CENTRED, not bottom-anchored. The card is scaled to fit below the bar now, so the reservation is
     handled by the card's own position and the aperture no longer has to be pushed down: flex-end left
     84px of slack above the phone and 0 below it. Centring splits that evenly — 42 top and bottom. */
  align-items: center;
  /* SCALED to fit the space the bar leaves, rather than only reserving it. --scan-scale is
     (100vh - 94) / 100vh, set in JS because CSS cannot divide two lengths to a unitless ratio.
     zoom rather than transform so the LAYOUT box shrinks too and the card still occupies exactly the
     remaining height. margin-top is divided by the scale so it renders as a literal 94 under the bar. */
  /* NO ZOOM HERE. .opt-scan__bg / __bg-still are children of this element, so scaling it scaled the
     BACKDROP too and pulled it inside the viewport — measured 24/24/62 of inset at 1440x900 and 33/33/65
     at 810. The scale lives on the foreground children instead (.opt-scan__phone, .opt-scan__intro), so
     the backdrop renders 1:1 and fills edge to edge whatever the card's scale is.
     The bar's band is reserved with padding, which moves the flex-centred foreground without touching the
     absolutely positioned backdrop. --hiw-band, not a 94 literal: the bar is 94 on desktop and 52 condensed,
     and a reservation that cannot follow it over-reserves by 42 through 431-767. */
  padding-top: var(--hiw-band);

  justify-content: center;
  overflow: hidden;
}

/* SCAN step chip + "YOUR SPACE" heading — top-left overlay. Left edge matches the SKILLS header's on-screen
   left. SKILLS is a content-scale deck card: .opt-skills__inner is an .opt-container (width W = min(1200, 100vw−240))
   grid-centred in the card, then transform:scale(--card-scale) about center. So its header's real left edge =
   (100vw − W·--card-scale)/2. SCAN is NOT --card-scale'd (native full-bleed), so we reproduce that same value
   here. The ≤430 mobile block (defined later) overrides this with its own 34px rail → wins at mobile widths. */
/* LEFT EDGE IS RELATIVE TO THE APERTURE, not the card. The node's 83.72 is measured from a card whose
   left edge and the aperture's coincided; once the card is scaled the aperture insets from it, and a
   card-relative offset let the chip and heading span the aperture's left boundary (measured intro at 75
   against an aperture at 117 — 42px outside it). Expressing the offset from the aperture's own edge keeps
   them inside at any scale: half the leftover width, plus the node's own 33px inset. */
.opt-scan__intro {
  zoom: var(--scan-scale, 1);   /* chip + heading are foreground; the left below is aperture-relative */
  /* The aperture's RENDERED left is (100vw - apertureWidth * scale) / 2, and this element is itself
     zoomed, so its own left has to be that target divided by the scale to land there. Written against
     the unscaled width before the foreground carried the zoom, it resolved 45px OUTSIDE the aperture. */
  --scan-ap-w: calc(100vw - 100vh + min(56vw, calc(100vh - var(--hiw-band))));
  /* REVERTED (was a proportional right-edge anchor at v=495, out at v=499). The block is anchored by its
     LEFT edge, 30 rendered px inside the aperture, and the aperture is meant to encompass chip and title
     both — pulling the block out into the gutter as the aperture narrowed was the wrong read of it. */
  left: calc(((100vw - var(--scan-ap-w) * var(--scan-scale, 1)) / 2 + 30px) / var(--scan-scale, 1));
  position: absolute;
  /* ONE FIXED POSITION for every beat — the block never moves, only its text changes. The seven comps
     give three different origins for the chip's top-left:
         91.72 / 161   low cube 2008:1005, dainty round 2008:1026, chunky 2008:1041, japanese 2008:1056
         50    / 161   blueprint 1998:700 and coffee tables 1999:890 (both at 42/153 + 8px padding)
         147   / 159.92  default 1999:829
     91.72 / 161 wins: it is what 4 of the 7 specify, and 161 is the top shared by 6 of the 7. The 8px
     padding is applied in EVERY beat (box 83.72 / 153 -> content 91.72 / 161) so the padding never toggles
     and the box itself never shifts either. This restores the 2026-06-20 lock's delta 0,0 invariant. */
  top: 153px;
  padding: 8px;
  z-index: 5;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 40px;                 /* chip → title — 1999:829 / 1998:700 / 1999:890 all use 40 (was 24) */
  pointer-events: none;      /* never intercept clicks meant for the phone aperture */
}
.opt-scan__heading {
  position: relative;        /* positioning context for the overlaid "GET INSPIRATION" variant */
  margin: 0;
  width: auto;               /* all three comps set the type whitespace-nowrap and size to content */
  white-space: nowrap;
  font-family: var(--font-body);  /* DEFAULT beat = Nunito Regular 49.2 #42604d (1999:839) */
  font-weight: 400;
  font-size: 48px;           /* ONE size in every beat. The default comp (1999:839) says 49.2 and the
                                blueprint/tables comps (2012:1213 / 1999:907) say 48; the 1.2px difference
                                was re-laying the title out on the single frame .is-expanded landed —
                                width -7.3px, height -3.0px, the subline's reserved slot up 7px — which is
                                what read as the flash. 48 is the value two of the three comps carry. */
  line-height: normal;
  color: #42604d;
  /* colour hands over ON the aperture: same 0.6s + cubic-bezier(0.22,1,0.36,1) as .opt-scan__phone's
     width/height, so the recolour and the expanding edge finish together (was 0.45s ease — it landed at
     ~534ms against the phone's ~600ms). opacity rides the tint's 0.3s — see the .is-sl-panel rule that
     drops the title while the chip stays forward. */
  transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
}
.opt-scan__heading-measure span { display: block; }
/* MEASURE / YOUR SPACE — two lines, in EVERY beat. The default beat (1959:42, block 1999:829 — the PiP
   phone/room video before the aperture expands) used to break one word per line; it now matches the
   expanded beats, so the break is unconditional and the old .is-expanded-only override is gone. The
   markup keeps real whitespace between the spans, so inlining 2+3 yields "YOUR SPACE" with its space. */
.opt-scan__heading-measure span:nth-child(2),
.opt-scan__heading-measure span:nth-child(3) { display: inline; }
/* feather/soft-landing IN — reuses the exact "for the perfect fit" effect (opacity 0→1 + translateY 10→0,
   1.1s easeOutCubic). REVEALED ON THE EXPAND (.is-expanded), not on the section (.is-active): before the
   aperture opens the block is the chip alone, and the title arrives as the iPhone window does. This is the
   trigger only — POSITION is untouched, the block still lands on the same top/left it holds in both states,
   so the 2026-06-20 Δ0,0 lock is intact; that lock governs where the title sits across the expand and the
   chip's expanded state, neither of which this changes. It also brings desktop into line with ≤430, which
   already held the intro back until the expand. The .is-tables fade-OUT keeps its quick 0.5s (below). */
.opt-scan__heading-measure { opacity: 0; transform: translateY(10px);
  transition: opacity 1.1s cubic-bezier(0.33, 1, 0.68, 1), transform 1.1s cubic-bezier(0.33, 1, 0.68, 1); }
.opt-scan.is-expanded .opt-scan__heading-measure { opacity: 1; transform: translateY(0); }
/* "GET INSPIRATION" — same font/size/position as the measure heading, overlaid at its origin. Dissolves IN
   on the table-AR handoff (.is-tables) exactly as "MEASURE YOUR SPACE" + the subtext clear. Reverts on collapse. */
.opt-scan__heading-inspo {
  position: absolute;
  top: 0;
  left: 0;
  width: auto;
  color: #42604D;            /* PICK YOUR PROJECT + "from our categories." — dark green, NOT the
                                #ebfded the blueprint beat inherits from the expanded heading */
  /* NO opacity/transform here any more. It is now a plain positioning container, exactly as the <h2> is for
     MEASURE YOUR SPACE + "for the perfect fit": each of its two children carries its own feather, so the
     subline animates as its own unit instead of inheriting the parent's (which compounded the parent's
     10px travel with the child's and would have made "from our categories." move twice as far). */
}
.opt-scan__heading-inspo span { display: block; }      /* PICK YOUR / PROJECT, then the subline */
/* The tables beat's two lines are INDEPENDENT elements with independent feathers — the pairing mirrors
   the blueprint beat exactly: pick <-> measure (title) and cats <-> fit (subline). Same motion on all
   four (opacity 0->1 + translateY 10px->0, 1.1s easeOutCubic); what makes each pair read as two moments
   rather than one block is that the subline arrives AFTER its title, never with it. */
/* "PICK YOUR PROJECT" — twin of .opt-scan__heading-measure. */
.opt-scan__heading-pick {
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 1.1s cubic-bezier(0.33, 1, 0.68, 1), transform 1.1s cubic-bezier(0.33, 1, 0.68, 1);
}
/* "from our categories." — twin of .opt-scan__heading-fit. */
.opt-scan__heading-cats {
  margin-top: 16px;          /* 1999:906 title -> "from our categories." gap */
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 1.1s cubic-bezier(0.33, 1, 0.68, 1), transform 1.1s cubic-bezier(0.33, 1, 0.68, 1);
}

/* CYCLING CATEGORY NAME (2009:1093 -> 2024:239, row 2024:265) — a SIBLING BELOW "from our categories.",
   24px under it (the 2024:258 column gap); the subline stays put. 12px #FF3567 marker, radius 3, then the
   name 16px to its right at 24px — half the subline's size, so it has to set its own size against the
   heading's 48px base. Note the base selector is scoped through .opt-scan__heading-inspo: that block's
   `span { display: block }` rule would otherwise out-specify a bare class and force the row visible.
   The row feathers in once as the cycle starts; the four names are stacked and crossfade at 0.3s — the
   same 0.3s the input-bar icons they track use. */
.opt-scan__heading-inspo .opt-scan__heading-catrow {
  display: none;
  align-items: center;
  gap: var(--cat-gap);
  margin-top: 24px;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.3s ease, transform 0.3s ease;
  /* --cat-size is the LANDING treatment — the single item this row has always shown. --cat-stack is what
     the names wear while they are a list (0.75x), which is the size the winner scales up FROM. Pitch is
     one ratio off the stack size, not a picked gap, so re-sizing the list re-spaces it. */
  --cat-size:  24px;
  --cat-stack: 18px;
  --cat-pitch: calc(var(--cat-stack) * 1.6);
  --cat-move:  0.7s;
  /* the bullet and the gap the names are indented past it — ONE source for the row marker, each name's
     own bullet, and the clip layer's left edge, so they cannot drift apart. */
  --cat-bullet: 12px;
  --cat-gap:    16px;
  --cat-indent: calc(var(--cat-bullet) + var(--cat-gap));
}
/* The row's standalone marker is now the FLOAT states' bullet only. While the categories cycle, every name
   carries its own (::before below) — all four display with a bullet, not just the last — so this one is
   hidden but keeps its box, which is what holds the 12 + 16 gap the names are indented by. */
.opt-scan__heading-catmark { flex-shrink: 0; width: var(--cat-bullet); height: var(--cat-bullet); border-radius: 3px; background: #FF3567; visibility: hidden; }
/* Every name is absolute now (they used to stack at one origin, first-in-flow), so this box would collapse
   and take the marker's centring with it. The ::before strut is a zero-width inline at --cat-size with the
   heading's own line-height: it reproduces EXACTLY the line box the single in-flow name used to make, so
   the row sits where it always sat and the marker still centres on the landing line — while the listed
   names, being smaller and absolute, hang below it without moving anything. */
.opt-scan__heading-catnames { position: relative; font-size: var(--cat-size); }
.opt-scan__heading-catnames::before { content: ""; display: inline-block; width: 0; }
.opt-scan__heading-catname {
  position: absolute;
  left: 0;
  top: 0;
  font-family: var(--font-ui);
  font-weight: 300;
  font-size: var(--cat-stack);
  color: #42604D;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(6px);
  /* arrival keeps the 0.3s the crossfade had (the cadence is unchanged). The resolve is the travel (top)
     with the scale arriving LATE on it: font-size starts at half the rise and runs 0.45s, so it finishes
     0.1s after the travel does — the name comes up, then settles and scales into its resting size, rather
     than growing the whole way up. */
  transition: opacity 0.3s ease, transform 0.3s ease,
              top var(--cat-move) cubic-bezier(0.33, 1, 0.68, 1),
              font-size 0.45s cubic-bezier(0.33, 1, 0.68, 1) calc(var(--cat-move) / 2);
}
/* list slots — shelving holds the row's own line, the other three hang beneath it on the pitch */
/* per-name bullet — pulled back over the hidden row marker's box (12 wide + the row's 16 gap), so each
   name's bullet sits exactly where the single row marker used to, and the riser's own bullet lands on
   that spot at rest. Vertically centred on the name's own line, as the row marker was on the row's. */
.opt-scan__heading-catname::before {
  content: "";
  position: absolute;
  left: calc(var(--cat-indent) * -1);
  top: 50%;
  transform: translateY(-50%);
  width: var(--cat-bullet); height: var(--cat-bullet); border-radius: 3px;
  background: #FF3567;
}
.opt-scan__heading-catname--bookcase { top: var(--cat-pitch); }
.opt-scan__heading-catname--video    { top: calc(var(--cat-pitch) * 2); }
.opt-scan__heading-catname--coffee   { top: calc(var(--cat-pitch) * 3); }
/* THE PASSED LAYER — the three names the riser goes up past. Its clip bottom is derived to sit exactly on
   the riser's top edge: the riser's top runs 3*pitch → 0 on --cat-move, and this inset's bottom runs
   pitch → 4*pitch on the SAME duration and easing, so (4*pitch − bottom) === the riser's top at every
   frame. That identity is the whole mechanism — the three are consumed as the riser reaches them, at its
   leading edge, instead of fading out on a timer that only approximates where it is. */
.opt-scan__heading-catpassed {
  position: absolute;
  top: 0;
  /* The names' own box is zero-width (its only in-flow content is the line strut), so a clip layer stretched
     to it would clip everything away horizontally. It starts at the BULLET column instead and is given a
     width nothing can reach — the cut is meant to be vertical only, and each bullet has to fall inside it.
     The names it holds give that pull back below, so their text still starts exactly where the unclipped
     riser's does. (Padding cannot do it: an absolute child lays out against the padding BOX, whose origin
     is the border edge, so padding-left would not have moved them.) */
  left: calc(var(--cat-indent) * -1);
  width: 20em;
  height: calc(var(--cat-pitch) * 4);
  clip-path: inset(0 0 var(--cat-pitch) 0);
  transition: clip-path var(--cat-move) cubic-bezier(0.33, 1, 0.68, 1);
}
/* gives back the pull above, so a clipped name sits at the same x as the unclipped riser */
.opt-scan__heading-catpassed .opt-scan__heading-catname { left: var(--cat-indent); }
.opt-scan.is-cat-1 .opt-scan__heading-catrow,
.opt-scan.is-cat-2 .opt-scan__heading-catrow,
.opt-scan.is-cat-3 .opt-scan__heading-catrow,
.opt-scan.is-cat-4 .opt-scan__heading-catrow { display: flex; opacity: 1; transform: translateY(0); }
/* CUMULATIVE: the names STACK rather than replace each other — is-cat-N leaves 1..N on screen, so each
   beat adds a line under the last one. The cadence is untouched; only what happens to the previous name
   changed (it stays). */
.opt-scan.is-cat-1 .opt-scan__heading-catname--shelves,
.opt-scan.is-cat-2 .opt-scan__heading-catname--shelves,
.opt-scan.is-cat-3 .opt-scan__heading-catname--shelves,
.opt-scan.is-cat-4 .opt-scan__heading-catname--shelves,
.opt-scan.is-cat-2 .opt-scan__heading-catname--bookcase,
.opt-scan.is-cat-3 .opt-scan__heading-catname--bookcase,
.opt-scan.is-cat-4 .opt-scan__heading-catname--bookcase,
.opt-scan.is-cat-3 .opt-scan__heading-catname--video,
.opt-scan.is-cat-4 .opt-scan__heading-catname--video,
.opt-scan.is-cat-4 .opt-scan__heading-catname--coffee { opacity: 1; transform: translateY(0); }
/* THE RESOLVE (is-cat-resolve, added a beat after the fourth name lands, is-cat-4 still on): the first
   three clear while "coffee tables" travels from the fourth slot to the row's own line and its type grows
   --cat-stack → --cat-size. Both properties are on the one transition above, so it reads as one move that
   ends in exactly the single-item state this row showed before: same slot, same treatment, same marker.
   Specificity is spelled out (.is-cat-4.is-cat-resolve) rather than left to source order — the cumulative
   rules above match the same elements at the same weight. */
.opt-scan.is-cat-4.is-cat-resolve .opt-scan__heading-catpassed { clip-path: inset(0 0 calc(var(--cat-pitch) * 4) 0); }
.opt-scan.is-cat-4.is-cat-resolve .opt-scan__heading-catname--coffee { top: 0; font-size: var(--cat-size); }
.opt-scan__heading-fit span { display: block; }        /* "for the" / "perfect fit" — two lines */
/* "for the perfect fit" — a LIGHTER-weight subtext line BELOW the heading (the heading stays). Reserves its
   space (opacity-only) and fades in once the measurement lines settle (.is-measured); reverts on collapse. */
.opt-scan__heading-fit {
  display: block;
  margin-top: 16px;          /* 1998:705 title -> subline gap (was 8) */
                             /* weight + colour now come from the .is-expanded rules above (was 200 / #00FFFF) */
  opacity: 0;
  transform: translateY(10px);
  /* Arrives as ONE unit WITH the pointer — same trigger frame, matched 1.1s duration + easeOutCubic easing
     (matches .is-pointer .opt-scan__pointer), so the subtext and the "HERE" pointer read as a single moment. */
  transition: opacity 1.1s cubic-bezier(0.33, 1, 0.68, 1), transform 1.1s cubic-bezier(0.33, 1, 0.68, 1);
}
.opt-scan.is-measured .opt-scan__heading-fit { opacity: 1; transform: translateY(0); }
/* BLUEPRINT + TABLES type: ABC Ginto Normal (var(--font-ui)) 48px #ebfded — Medium for the title line,
   Light for the subline. NOTE: ABC Ginto Normal has no @font-face here, so it renders only where it is
   installed locally and otherwise falls back to Barlow (weight 500 added to the Barlow request so the
   Medium is a real cut rather than a synthesised one). Colour-state rule + its 0.45s transition kept. */
/* NB: no font-family here. Every title and subline sets its own face, so switching the <h2>'s family on
   expand changed nothing visible — but .opt-scan__heading-measure is an inline box wrapping block lines,
   so the parent font's strut still re-measured on that frame. Colour only. */
.opt-scan.is-expanded .opt-scan__heading { color: #ebfded; }
/* TITLES — Bricolage Grotesque Medium at opsz 14 in EVERY beat (1999:839 default, 2012:1213 blueprint,
   1999:907 tables). Two titles, not three: the COFFEE TABLES variant that used to ride this selector is
   deleted, the tables beats keeping PICK YOUR PROJECT. Set on the title elements
   rather than the <h2> so the sublines below keep their own face. Note the axes differ from the chip
   label's: opsz 14 here vs opsz 96 there — the webfont is now requested across its full axis ranges. */
.opt-scan__heading-measure,
.opt-scan__heading-pick {
  font-family: var(--font-bricolage);
  font-weight: 500;                                 /* Medium */
  font-variation-settings: 'opsz' 14, 'wdth' 100;
}
/* SUBLINES — unchanged, still ABC Ginto Normal Light (2012:1215 / 1999:909). */
.opt-scan__heading-fit,
.opt-scan__heading-cats {
  font-family: var(--font-ui);
  font-weight: 300;
}
/* expanded beats break the title MEASURE / YOUR SPACE (1998:706), not one word per line. The markup keeps
   real whitespace between the spans, so flipping 2+3 to inline yields "YOUR SPACE" with its space. */
/* table-AR handoff (.is-tables, 0.5s): "MEASURE YOUR SPACE" + the "for the / perfect fit" subtext clear and
   "GET INSPIRATION" dissolves IN to take their place. All revert on collapse (classes removed → initial state). */
.opt-scan.is-tables .opt-scan__heading-measure { opacity: 0; transition: opacity 0.5s ease; }   /* quick fade-OUT (override the 1.1s feather-in) */
.opt-scan.is-tables .opt-scan__heading-fit { opacity: 0; transition: opacity 0.5s ease; }
/* title lands ON the beat, as MEASURE YOUR SPACE lands on .is-active */
.opt-scan.is-tables .opt-scan__heading-pick { opacity: 1; transform: translateY(0); }
/* subline lands AFTER its title, as "for the perfect fit." lands a beat later than MEASURE YOUR SPACE
   instead of with it. The offset is a SELF-CONTAINED delay, deliberately NOT hooked to a sequence class:
   tying it to .is-first-table would entangle this text with the nav bar's inaugural feather, so retuning
   either one would move the other. Nothing outside this rule affects the subline's timing, and this rule
   affects nothing outside itself. The value is derived rather than picked: 1.1s is the title's own feather
   duration, so the subline starts exactly as PICK YOUR PROJECT settles. The delay sits on the REVEAL rule
   only, so leaving the beat reverses immediately — no lingering, matching the fit line's quick clear. */
.opt-scan.is-tables .opt-scan__heading-cats { opacity: 1; transform: translateY(0); transition-delay: 1.1s; }
/* NO TITLE SWAP ON THE PICK. The block that PICK YOUR PROJECT / "from our categories." / "• coffee tables"
   resolves into is the state the whole AR table cycle now runs under — it does not clear, and the uppercase
   COFFEE TABLES variant never takes the slot. .is-category-picked still marks the pick for the icon and the
   marker; it just no longer touches the title. The COFFEE TABLES variant itself — markup, base rule and
   its place in the two type selectors — is DELETED: nothing revealed it after the swap came out, so it
   was a heading the page carried and could never show. */
/* The "Choose it." chip is present in ALL THREE comps (1999:852 default, 1999:768 blueprint, 1999:932
   tables), so the fade-out-on-expand half of the 2026-06-20 lock is gone. Its transition is left in place
   (no timing/easing change); nothing drives it to 0 on desktop any more. ≤430 keeps its own chip states. */
.opt-scan__intro .opt-chip { transition: opacity 0.45s ease; }

/* full-bleed room video — blurred backdrop. Sized to the viewport and centred so it
   is pixel-identical to the sharp in-phone layer (the phone is an aperture over it).
   cover + bottom-right crop; scale hides blur-edge bleed. */
.opt-scan__bg {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 100vw;
  height: 100vh;
  transform: translate(-50%, -50%) scale(1.08);
  object-fit: cover;
  object-position: 100% 100%;
  filter: blur(14px);   /* only blur differs from the sharp layer — no tonal step at the seam */
  pointer-events: none;
  transition: opacity 0.45s ease;
}

/* iPhone (1164:1001) — 368×591 aperture over the backdrop; overflow clips the
   full-bleed sharp video to the phone rect. Expand widens the width only. */
/* THE FOREGROUND CARRIES THE SCALE, the backdrop does not — see the note on .opt-scan. */
.opt-scan__phone {
  zoom: var(--scan-scale, 1);
  position: relative;
  width: 368px;
  height: 591px;
  border-radius: 42px;
  overflow: hidden;
  background: #000;
  container-type: inline-size;   /* query container for the cube/round float bars' breakpoint sizing */
  container-name: aperture;
  box-shadow: 0 30px 80px rgba(20, 32, 26, 0.45);
  transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1),
              height 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}

/* end state — the clear aperture grows ~20% (width AND height) to reveal MORE of the apt
   image + blueprint in the clear space. NO transform scale: the still/tint/blueprint are
   100vw/100vh cover layers clipped by this phone, so enlarging the clip rect un-clips more
   of them at the SAME content scale (no zoom). They stay locked because they share this
   parent. Even blur frame preserved by re-deriving: with no transform, even frame needs
   width − height = 100vw − 100vh, so width = 100vw − 100vh + height; height = 591 × 1.2. */
.opt-scan.is-expanded .opt-scan__phone {
  /* Height tracks viewport WIDTH, not fixed px. The content cover is width-driven (square 2779²
     source in a landscape box), so the visible image fraction ≈ clipHeight / vw; a fixed-px height
     therefore shrank that fraction on wider monitors and dropped the couch base / floor / "here"
     pointer below the clip. 56vw ≈ image-fraction 0.78 at the bottom edge → comfortable floor plane
     + pointer at every width. Still NO transform: scale() — more floor comes from un-clipping more
     image, never stretching. The cap is now the space BELOW the sticky bar, not 90vh: at 1440x810 the
     90vh cap gave 729 against 716 of room, so a bottom-aligned phone still tucked 13px under the bar
     (24 at 1440x700). calc(100vh - 94px) can never exceed what the reservation leaves. It was 90vh so
     the clip never exceeds the viewport height on 16:9 /
     ultrawide; there the even frame is preserved and floor degrades gracefully (never below the old
     fixed-px framing). */
  height: min(56vw, calc(100vh - var(--hiw-band)));
  /* Even frame — relation unchanged (width = 100vw − 100vh + height), height term re-derived. Using
     the SAME min() term keeps the four bands equal in both the 56vw and the 90vh-capped regime. */
  width: calc(100vw - 100vh + min(56vw, calc(100vh - var(--hiw-band))));
}

/* sharp in-phone layer — IDENTICAL size/position/crop/scale to .opt-scan__bg (shares
   source + playback time), just unblurred. Centred in the phone, which sits at the
   section centre, so it overlays the backdrop exactly: the phone edge is an invisible
   seam. 100vw wide → clipped by the phone's overflow to form the aperture. */
.opt-scan__phone-video {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 100vw;
  height: 100vh;
  transform: translate(-50%, -50%) scale(1.08);
  object-fit: cover;
  object-position: 100% 100%;
  display: block;
  transition: opacity 0.45s ease;
}

/* end-still (Figma export, couch placed) — same framing/scale as the live layers so the
   aperture seam stays invisible. bg-still is the blurred full-bleed backdrop; phone-still
   the sharp in-phone layer. Both share the same 100vw/100vh cover geometry as .opt-scan__bg
   / .opt-scan__phone-video, so the swap holds the framing. Hidden during playback; crossfade
   in on expand (end state). */
.opt-scan__bg-still {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 100vw;
  height: 100vh;
  transform: translate(-50%, -50%) scale(1.08);
  object-fit: cover;
  object-position: 100% 62%;   /* right; shifted DOWN from 50% (v=72 was 74% — overcorrected, lost too
                                  much window top). 62% is the 50%-reduced shift: measured at 16:10 the
                                  lowest table base still clears the nav top by ~122px (15% of clip h),
                                  recovering ~half the top. Must match the aperture layers' value so the
                                  seam stays invisible. */
  opacity: 0;
  transition: opacity 0.45s ease;
  pointer-events: none;
  filter: blur(14px);
}

/* in-phone still + couch blueprint — SHARED render geometry. Both sources are the same
   2779² square (the apt still + a blueprint authored on a 2779² canvas with the couch over
   the couch), so identical box + object-fit:cover + object-position make them cover-crop in
   lockstep at every breakpoint — the couch tracks automatically, no baked alignment.
   Per-element opacity/transition (and the expand fades) live in the rules below. */
/* shared cover-crop geometry — still, blueprint, table imgs AND the pointer WRAPPER all share this
   box + transform so they cover-crop in lockstep (locked in front of the couch). */
.opt-scan__phone-still,
.opt-scan__blueprint,
.opt-scan__blueprint-m,
.opt-scan__pointer-m,
.opt-scan__table,
.opt-scan__marker,
.opt-scan__pointer {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 100vw;
  height: 100vh;
  transform: translate(-50%, -50%) scale(1.08);
  pointer-events: none;
}
/* object-fit on the img layers + the pointer's inner img — identical crop so they register.
   object-position vertical shifted DOWN from 50% → 62%: the cover crop reveals the floor in FRONT of
   the couch so every table base reads inside the window clear of the nav bar at all viewport sizes
   (top traded for bottom). 62% is the 50%-reduced shift after v=72's 74% overcorrected (too much top
   lost); measured at 16:10, lowest table base clears nav top by ~122px. All four shared-crop layers
   move together → blueprint/couch/table/pointer stay locked; bg-still uses the same value (seam invisible). */
.opt-scan__phone-still,
.opt-scan__blueprint,
.opt-scan__blueprint-m,
.opt-scan__pointer-m,
.opt-scan__table,
.opt-scan__pointer img {
  object-fit: cover;
  object-position: 100% 62%;
}
.opt-scan__pointer img { width: 100%; height: 100%; display: block; }
/* ≤430 mobile-only overlays — hidden on desktop; the full-bleed mobile sequence (≤430 SCAN block) reveals them. */
.opt-scan__blueprint-m, .opt-scan__pointer-m { display: none; }
.opt-scan__phone-still {
  opacity: 0;
  transition: opacity 0.45s ease;
}

/* end state: still swaps in, live video swaps out */
.opt-scan.is-expanded .opt-scan__bg-still,
.opt-scan.is-expanded .opt-scan__phone-still { opacity: 1; }
.opt-scan.is-expanded .opt-scan__bg,
.opt-scan.is-expanded .opt-scan__phone-video { opacity: 0; }

/* 75% black tint over the still — darkens the apt so the cyan blueprint lines (which paint
   IN FRONT of it, per DOM order) read with contrast. Covers the aperture; fades in with the
   still on expand. Sits above .opt-scan__phone-still, below .opt-scan__blueprint. */
.opt-scan__tint {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.65);
  opacity: 0;
  transition: opacity 0.45s ease;
  pointer-events: none;
}
.opt-scan.is-expanded .opt-scan__tint { opacity: 1; }

/* couch blueprint overlay (Figma 1283-66) — placed SVG carries the couch linework at
   its position in the still's square source, rendered with the SAME cover-crop so it
   tracks the real couch. Dissolves in after the expansion settles; reverses on collapse. */
.opt-scan__blueprint {
  opacity: 0;
  transition: opacity 0.5s ease;       /* collapse → fades out immediately */
}
.opt-scan.is-expanded .opt-scan__blueprint {
  opacity: 1;
  transition: opacity 0.5s ease 0.75s; /* expand → fades in after the widening settles */
}

/* "here" pointer — separate 2779² asset, same shared-crop geometry as the still/blueprint so
   it tracks the apt. Fades in ONE BEAT after the blueprint dissolve completes: blueprint runs
   0.75s delay + 0.5s = settles at 1.25s; pointer starts at 1.75s (1.25 + a 0.5s beat). */
.opt-scan__pointer {
  opacity: 0;
  transition: opacity 0.5s ease;       /* collapse → fades out immediately */
}
.opt-scan.is-pointer .opt-scan__pointer {
  opacity: 1;
  /* PHASE 3 (fall/landing): softened to a gentle 1.1s ease-out settle (was a snappy 0.8s ease-in accel).
     The come-down is carried by the timing/hold, not a hard curve. Gated on .is-pointer (after the apex). */
  transition: opacity 1.1s cubic-bezier(0.33, 1, 0.68, 1);
}

/* scale-bump entrance — thumbnail-style (delta 0.055, 0.3s, ease) but SEQUENCED AFTER the opacity fade-in
   so it's visible. The pointer HOLDS at 1.055 through the dissolve (opacity 1.75s delay + 0.8s → opaque at
   2.55s), then the bump settles 1.055→1.0 with the scale delayed to **2.55s** (= opacity delay + duration)
   → no overlap with the fade; the bump only begins once opacity = 1, so it actually paints. (Earlier it
   shared the 1.75s beat with the fade → resolved at ~0 opacity → invisible; this is the v=113/114 hold,
   now at the thumbnail magnitude/easing.) Scaled about the BUBBLE CENTER (55.6% / 70.9%) so it stays in
   place — no drop (the "here" cyan dot + HERE path at SVG cx 1544.69 / cy 1862.13 maps through the
   object-fit:cover + object-position 100% 62% crop to ≈55.6%/70.9% of the img box; bubble = the scale's
   fixed point, ≈0px drift). Table-sequence hand-off is a JS timer after the bump settles. */
.opt-scan__pointer img {
  transform: scale(1.3715);                /* apex = resting 1.3 × 1.055 entrance bump (was 1.1605) — pointer made more prominent */
  transform-origin: 55.6% 70.9%;         /* bubble center — fixed point of the scale → in place, no drop */
  transition: transform 0.3s ease;       /* thumbnail spec; collapse → scales back toward rest as it fades out */
}
.opt-scan.is-pointer .opt-scan__pointer img {
  transform: scale(1.3);                   /* resting scale bumped up (was 1.1) for prominence */
  /* SYNCED with the "perfect fit" subtext: same 1.1s easeOutCubic, NO delay, so the scale settles in
     lockstep with the text fade-in (both fire on the is-measured/is-pointer frame) and read as one unit. */
  transition: transform 1.1s cubic-bezier(0.33, 1, 0.68, 1);
}

/* on .is-tables (set after the click-pulse): blueprint + tint + pointer dissolve out together,
   leaving the un-tinted apt still for the table sequence. Placed after the is-expanded rules so
   it wins on source order at equal specificity. */
.opt-scan.is-tables .opt-scan__blueprint,
.opt-scan.is-tables .opt-scan__tint,
.opt-scan.is-tables .opt-scan__pointer {
  opacity: 0;
  transition: opacity 0.5s ease;
}

/* ── Measurement lines (split from blueprint-measurement-lns.svg into 6 per-line layers) ──
   Rendered between the blueprint reveal and the pointer; each rides the shared cover-crop
   (identical geometry to .opt-scan__blueprint) and "extrudes" via clip-path in its own direction.
   ALL SIX START ON THE SAME FRAME (single .is-measuring trigger). The clip-path DURATION is set
   per line ∝ its length, so they FINISH staggered (longest = room width); per-line labels can then
   fade as each reaches rest. NOTE: the export carries no label glyphs yet — labels not rendered. */
.opt-scan__measure {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 100vw;
  height: 100vh;
  transform: translate(-50%, -50%) scale(1.08);
  object-fit: cover;
  object-position: 100% 62%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s cubic-bezier(0.33, 1, 0.68, 1), clip-path 0.75s cubic-bezier(0.33, 1, 0.68, 1);
}
/* extrude start state (clipped from the growth-origin edge) + per-line duration ∝ length (staggered finish) */
/* PHASE 1 (rise): each line DECELERATES into its rest (ease-out) — the arc rising and settling into the apex */
/* softened: gentler ease-out (easeOutCubic) + longer extrude/fade for a smooth settle, not a snappy reveal */
/* RETIMED (was: duration ∝ sweep length, all six at delay 0). The old scheme timed the CLIP SWEEP,
   not the REVEAL. Each inset() animates across the whole element box (1440px wide / 900px tall at
   1440×900), but every line occupies only a slice of it — so a line stayed invisible while the clip
   edge travelled toward it, appeared only as the edge crossed it, and finished long before the
   transition ended. Measured from the path bounds of each SVG through the cover crop (×0.51817,
   object-position 100% 62% → 334.8px top crop), the shipped values gave:
     entries scattered 0.045s–0.156s (a 111ms spread, NOT one frame as the old comment claimed)
     reveals 0.075s–0.440s (5.9× spread — sofa-height flashed in ~4.5 frames)
     extrusion speed 1265–3335 px/s (2.6× spread, though the intent was constant speed)
   That accidental unevenness is the jitter: confirmed by A/B (disable the extrude → jitter gone) and
   consistent with rAF + long-animation-frame finding no main-thread stall at this beat on any loop.
   Now each duration is DERIVED so the line's own reveal window lasts 380ms at matched speed, and a
   per-line delay places the entries on a deliberate 70ms cadence, shortest line first. Negative delays
   are intentional and valid: they start the transition already in progress, skipping the dead lead-in
   while the clip edge crosses empty canvas — that dead time is what produced the old stagger.
   Opacity is now per-line too (0.25s, delay = the line's entry) so every line is opaque as it extrudes;
   the old shared 0.6s fade meant early lines faded WHILE extruding and later ones didn't, which broke
   the group's coherence a second way. Cadence values (380ms reveal / 70ms stagger / order) are the
   tunable part; the durations and delays are computed from them, not hand-picked. */
.opt-scan__measure--sofa-height { clip-path: inset(100% 0 0 0);  /* enters 0.000s, rests 0.380s */
  transition: opacity 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s,
              clip-path 3.05s cubic-bezier(0.33, 1, 0.68, 1) -0.23s; }
.opt-scan__measure--sofa-depth  { clip-path: inset(0 100% 0 0);  /* enters 0.070s, rests 0.450s — left→right */
  transition: opacity 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0.07s,
              clip-path 2.69s cubic-bezier(0.33, 1, 0.68, 1) -0.49s; }
.opt-scan__measure--window-a    { clip-path: inset(100% 0 0 0);  /* enters 0.140s, rests 0.520s */
  transition: opacity 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0.14s,
              clip-path 1.28s cubic-bezier(0.33, 1, 0.68, 1) -0.13s; }
.opt-scan__measure--window-b    { clip-path: inset(100% 0 0 0);  /* enters 0.210s, rests 0.590s */
  transition: opacity 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0.21s,
              clip-path 1.25s cubic-bezier(0.33, 1, 0.68, 1) -0.04s; }
.opt-scan__measure--room-height { clip-path: inset(100% 0 0 0);  /* enters 0.280s, rests 0.660s */
  transition: opacity 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0.28s,
              clip-path 0.80s cubic-bezier(0.33, 1, 0.68, 1) 0.17s; }
.opt-scan__measure--room-width  { clip-path: inset(0 0 0 100%);  /* enters 0.350s, rests 0.730s — right→left */
  transition: opacity 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0.35s,
              clip-path 0.95s cubic-bezier(0.33, 1, 0.68, 1) 0.28s; }
/* SINGLE trigger — all six begin extruding on the same frame; the per-line durations above stagger only the finish */
.opt-scan.is-measuring .opt-scan__measure { opacity: 1; clip-path: inset(0 0 0 0); }
/* dissolve out with blueprint/tint/pointer at the table handoff */
.opt-scan.is-tables .opt-scan__measure { opacity: 0; transition: opacity 0.5s ease; }

/* ── Measurement LABELS — the #F9F9F9 outline text baked into measurement-lines.svg, split into 6 layers.
   ALL SIX fade in TOGETHER in one beat, once the LAST line has settled: same .is-measuring trigger, with a
   single shared 0.90s delay (= the longest line's extrude duration, room width) so the fade starts right as
   the last line reaches its end-state. Same shared cover-crop as the lines. */
.opt-scan__measure-label {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 100vw;
  height: 100vh;
  transform: translate(-50%, -50%) scale(1.08);
  object-fit: cover;
  object-position: 100% 62%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.8s cubic-bezier(0.33, 1, 0.68, 1) 0.73s;   /* 0.8s gentle fade; delay tracks the LAST line's rest — retimed 1.10s → 0.73s (room-width now rests at 0.730s, not 1.10s) so the labels still arrive as the final line settles, not 370ms after it */
}
.opt-scan.is-measuring .opt-scan__measure-label { opacity: 1; }
.opt-scan.is-tables .opt-scan__measure-label { opacity: 0; transition: opacity 0.5s ease; }   /* dissolve out with everything else */

/* table layers — same shared-crop as the still (locked over the couch). Dissolve in/out one at a
   time via .is-active (JS loop); only one active at a time. 0.5s ease matches the blueprint feel. */
.opt-scan__table {
  opacity: 0;
  transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1);   /* FADE-OUT (is-active removed): 0.6s — matches the JS TABLE_FADE (900/1.5) */
}
/* FADE-IN (is-active added): softer + longer ease-in-out so the dissolve-in — especially the inaugural
   cube when the sequence starts — is smooth, not snappy. The gentle ease-in start removes the snap;
   completes ~1.2s, comfortably before the 1.4s leg cue. Shared across all tables' fade-in; the fade-out
   stays 0.9s above so the JS advance rhythm is intact. */
.opt-scan__table.is-active { opacity: 1; transition: opacity 0.8s ease-in-out; }   /* fade-IN follows the same 1.5x (1.2s → 0.8s); still well inside the 1533ms dwell */

/* NOTE: the old full-width bottom-pinned bar (.opt-scan__nav) is RETIRED — table 4 (japanese) is now the
   .opt-scan__nav-float--japanese 600×60 float like tables 1–3. The .opt-scan__nav-* child rules below stay:
   they are shared layout/typography used by ALL the float bars. */
.opt-scan__nav-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 100%;
  /* Shared layout for ALL nav bars — the 3 floats (cube/round/chunky) AND the full-width bottom bar
     (table 4 / japanese): left inset 16px before the icons, right inset 0 (the arrow carries its own
     right inset), 4px top/bottom + align-items:center → vertical centering. flex justify-between pins
     the left cluster to the bar's left and the right cluster to the bar's RIGHT edge at ANY width, so
     the fluid bottom bar and the fixed 600px floats share one treatment with no absolute offsets.
     (Was 0 20cqh 0 4.25cqw — a bottom-bar-only inset that mirrored the 118px arrow width.) */
  padding: 4px 0 4px 16px;
  gap: 10cqh;
}
.opt-scan__nav-left,
.opt-scan__nav-right { display: flex; align-items: center; }
/* icons ↔ table name. Expressed in cqh like its siblings so it tracks the bar height rather than being
   pinned: the comp's 8px sits in a 47.808px bar = ~17% of it, which is 17cqh here and lands ~10px in our
   taller 60px bar. Was 32cqh (~19px) — roughly double the comp's proportion. */
.opt-scan__nav-left { gap: 17cqh; }
.opt-scan__nav-right { gap: 14cqh; }
/* JAPANESE bar keeps the shared right-aligned layout (1988:927): checkmark + arrow pinned to the bar's
   right edge by the row's justify-between. The hole that packing-left was fixing came from the bar being
   600px wide against ~330px of content — with the bar now sized to its content there is no slack to
   spread, so space-between simply seats the two clusters at the two edges. */
.opt-scan__nav-float--japanese .opt-scan__nav-row { gap: 8px; }

.opt-scan__nav-title {
  font-family: var(--font-body);
  font-weight: 600;   /* SemiBold — static, holds constantly (no animation). Nunito 600 is in the import. */
  font-size: 17cqh;
  letter-spacing: 0.07em;
  color: #42604D;                 /* dark green — reads against the glass bar */
  white-space: nowrap;
}
.opt-scan__nav-icons { display: flex; gap: 6cqh; }
.opt-scan__nav-icon { height: 50cqh; width: auto; display: block; }


.opt-scan__nav-check { display: block; flex-shrink: 0; }
/* checkmark states stacked: green at rest (Icon-checkmark-nav), pink on the shopping-list beat
   (Icon-checkmark-nav-on, #FF3567 disc + white glyph — both read straight off 2028:72 / 2032:47). */
.opt-scan__nav-checks { position: relative; display: block; flex-shrink: 0; }
.opt-scan__nav-check--on { position: absolute; left: 0; top: 0; opacity: 0; }
.opt-scan__nav-check, .opt-scan__nav-check--on { transition: opacity 0.3s ease; }
/* :not() matters — the pink img carries BOTH classes, so a bare .opt-scan__nav-check hide (4 classes)
   out-specifies the .opt-scan__nav-check--on show (3) and blanks the very thing it should reveal. */
.opt-scan.is-sl-check .opt-scan__nav-checks .opt-scan__nav-check:not(.opt-scan__nav-check--on) { opacity: 0; }
.opt-scan.is-sl-check .opt-scan__nav-check--on { opacity: 1; }

/* CART TILE -> "What you'll need" PILL (1988:928 2024:391 -> 2024:402). At rest a 30x30 #5D8568 tile,
   radius 4.5, holding the 17.5x15 cart glyph. On .is-sl-cart it recolours to #42604D and the glyph swaps
   #E6F8E9 -> white (both baked into their own asset). On .is-sl-open it unfurls to 120x30 / radius 24 and
   the label fades in behind the widening box. Width is the only thing that animates the layout, so the
   like icon beside it slides rather than jumps. */
.opt-scan__nav-cart {
  box-sizing: border-box;
  flex-shrink: 0;
  width: 30px;
  height: 30px;
  padding: 5px;
  border-radius: 4.5px;
  background: #5D8568;
  display: flex;
  align-items: center;
  justify-content: flex-start;   /* NOT center: the label is laid out (opacity 0, flex-shrink 0) even at
                                    rest, so centring an over-wide row pushed the glyph out of the 30px
                                    tile. Anchored left, the glyph sits on the padding edge in every state
                                    and overflow:hidden clips the label until the pill unfurls. */
  gap: 5px;
  overflow: hidden;
  transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1),
              background-color 0.3s ease,
              border-radius 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.opt-scan__nav-cart-glyphs { position: relative; flex-shrink: 0; width: 17.5px; height: 15px; }
.opt-scan__nav-cart-glyph { position: absolute; left: 0; top: 0; width: 17.5px; height: 15px; display: block;
  transition: opacity 0.3s ease; }
.opt-scan__nav-cart-glyph--on { opacity: 0; }
.opt-scan__nav-cart-lbl {
  flex-shrink: 0;
  font-family: var(--font-body);   /* comp says Nunito Sans; not loaded here — see report */
  font-size: 11px;
  line-height: 22px;
  color: #FFFFFF;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.3s ease 0.2s;   /* trails the width so the label appears inside a box that fits it */
}
/* state 2 — glyph goes white on the RESTING tile (background unchanged) */
.opt-scan.is-sl-glyph .opt-scan__nav-cart-glyph:not(.opt-scan__nav-cart-glyph--on) { opacity: 0; }
.opt-scan.is-sl-glyph .opt-scan__nav-cart-glyph--on { opacity: 1; }
/* state 3 — background goes dark green, white glyph carried over */
.opt-scan.is-sl-cart .opt-scan__nav-cart { background: #42604D; }
.opt-scan.is-sl-open .opt-scan__nav-cart { width: 120px; border-radius: 24px; }
.opt-scan.is-sl-open .opt-scan__nav-cart-lbl { opacity: 1; }

/* Beat cover — black at 75% over the room, arriving and leaving WITH the panel (same 0.3s opacity ramp,
   so they reach full together). z-index 1 puts it above the room still and the table images but below the
   nav float (2) and the panel (6), so neither changes. Distinct from .opt-scan__tint, which is the
   section's own expand tint on a different trigger and already at 0 by this beat. */
.opt-scan__shoplist-tint {
  position: absolute;
  inset: 0;
  z-index: 1;
  background: rgba(0, 0, 0, 0.75);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}
.opt-scan.is-sl-panel .opt-scan__shoplist-tint { opacity: 1; }
/* The float is frosted — backdrop-filter: blur(6px) over rgba(255,255,255,.12) — so it samples the tint
   behind it and goes dark with it, even sitting above it at z-index 2. Nothing in CSS can exclude the
   tint from its backdrop, so the bar's own fill is lifted for this beat to hold the same read it had over
   the bright floor. Reverts with the tint; the other three bars never see this. */
.opt-scan.is-sl-panel .opt-scan__nav-float--japanese { background: rgba(255, 255, 255, 0.72); }
/* (.is-sl-shut is gone with the glide-out: the tint and the lifted float now leave inside the handoff,
   with everything else in the aperture.) */
/* The phone is z-index:auto while the chip/title block is z-index:5 on the section, so the block paints
   ABOVE everything in the phone — which is why it reads through the panel. Lift the phone over it for
   this beat only; the cover and the panel then land on top of the block instead of under it. */
.opt-scan.is-sl-panel .opt-scan__phone { z-index: 6; }
/* ...EXCEPT THE CHIP. Lifting the phone buried the whole block, chip included, and the chip then read as
   cut by the aperture's edge — it is a section-level marker, not part of the panel's scene. The intro goes
   back over the phone for this beat and the TITLE alone is hidden, which is the half the lift was actually
   for: the title is 300+px wide and its letters read through the panel's left edge, the chip is 88 and
   clears it. One rule forward, one rule down, instead of one rule burying both.
   The heading's own children keep their beat transitions; this fades the block as a whole, matched to the
   tint's 0.3s so the two land together. */
.opt-scan.is-sl-panel .opt-scan__intro { z-index: 7; }
.opt-scan.is-sl-panel .opt-scan__heading { opacity: 0; }
/* THE CHIP GOES TOO — AT 1440 AND BELOW. 1440 is the CEILING, not a floor: above it the design width has
   the room for both chips, so xLg keeps them (the lift above holds "Choose it." forward and uncut there).
   From 1440 down the panel's own "Shopping list" chip is the only one on screen for this beat.
   1.05s matches slSlide()'s own tween, so "Choose it." clears exactly as the panel arrives rather than
   blinking out ahead of it. The z-index lift is what makes this a FADE rather than a CUT — without it the
   phone (z-index 6 for this beat) buries the intro the instant the state lands and the chip disappears
   behind the aperture's edge mid-fade instead of fading in place. Lift, then fade. <=430 already did this
   via the .is-tables rule in its own block, which is on for the whole beat. */
@media (max-width: 1440px) {
  .opt-scan.is-sl-panel .opt-scan__intro .opt-chip { opacity: 0; transition: opacity 1.05s ease; }
}

/* ── "coffee tables" on the FOUR TABLE BEATS (1987:450 -> row 2024:233) ────────────────────────────────
   THE TABLE BEATS INHERIT THE RESOLVED BLOCK; THEY NO LONGER REBUILD IT. This block used to drop
   PICK YOUR PROJECT / "from our categories." out of flow and promote the row into a standing 48px label
   with its own 16px marker — the "reduce to the coffee-tables line" the spec rules out. The four float
   states now style nothing in the heading at all: cycleCategories() leaves .is-cat-4 and .is-cat-resolve
   ON when it hands over, so the settled state — full title, subline, and "coffee tables" at --cat-size
   with its own bullet — simply stays up for the whole rotation.
   RULE #8, what the old block fixed that this must not lose: it dropped the pair out of flow because they
   were opacity-0 and their held space pushed the row to the foot of the frame. They are VISIBLE now, so
   the space they hold is the layout rather than a gap — the row sits under them, which is the comp. */

/* ── COFFEE TABLES selection marker (2009:1093) ────────────────────────────────────────────────────────
   The blueprint beat's "here" pointer moves here and becomes a plain disc. Sized and placed against the
   SHARED CROP BOX (above), not the frame. The comp's own 56.46%/79.26% are percentages of ITS 1440x810
   frame, and our cover-crop frames the room differently — reading them across put the disc on the floor
   near the nav bar. The couch anchor that transfers is the one the "here" bubble already used: 55.6%/70.9%
   OF THE CROP BOX, documented on .opt-scan__pointer img. Diameter follows the comp's 47-in-1440 ratio
   against the same box, so it scales with the crop rather than being pinned. */
/* NO z-index: the marker must sit UNDER the table images so the table covers it as it lands. It precedes
   them in the DOM and they are all z-auto, so document order does the occluding. */
.opt-scan__marker { opacity: 0; pointer-events: none;
  /* In on the PICK and OUT again the moment the cube lands on it. The in-fade is quick (0.45s) so it is
     settled well before startTables fires 600ms later — 1.2s was still climbing as the table arrived. */
  transition: opacity 0.45s cubic-bezier(0.22, 1, 0.36, 1); }
.opt-scan.is-category-picked .opt-scan__marker { opacity: 1; }
/* 2040:274 "Here" — a pink disc carrying white "here" text, not a bare circle. Sized off the crop box so
   it scales with the room: the node's 48px against its 1440 frame is 3.33%, and the label is set as a
   share of the disc (em) rather than a fixed size so the two never drift apart.
   ANCHOR: left/top below are now a FALLBACK. main.js re-derives them from the live cover-crop at each
   reveal (scanPip -> anchorMarker) so the disc tracks the couch instead of the frame; these values are
   exactly what that computation returns at the reference viewport 1440x810, so desktop-16:10 is
   unchanged and every other aspect is corrected toward it. They still render verbatim whenever the JS
   bails — ≤430, image not yet decoded, or a crop that is no longer `cover`. Keep them in sync with
   MX/MY in main.js if this point is ever re-tuned.
   Deriving it from the node's 779/609 by frame->crop-box conversion gives
   55.34%/76.06%, which is geometrically correct but lands at the bottom of OUR frame — our cover-crop
   (object-position 100% 62%) frames the room quite differently from the comp, so the node's coordinates
   do not carry across even when converted properly. This value was tuned against our own render. */
.opt-scan__marker span {
  position: absolute;
  left: 56%;
  top: 66%;
  width: 3.09%;
  aspect-ratio: 1;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: #FF3567;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 1.05cqw;
  line-height: 1;
  color: #FFFFFF;
}
/* the cube's arrival takes it away — a fast clear as the table covers the spot, and it does not come back
   for round/chunky/japanese. Placed after the .is-category-picked reveal so it wins on source order. */
.opt-scan.is-cube-float .opt-scan__marker,
.opt-scan.is-round-float .opt-scan__marker,
.opt-scan.is-chunky-float .opt-scan__marker,
.opt-scan.is-japanese-float .opt-scan__marker {
  opacity: 0;
  /* visibility follows the fade and then LATCHES the marker off. Opacity alone left it one rule-order
     accident away from re-showing — .is-category-picked stays on for the whole table sequence, so its
     reveal is live underneath these the entire time. This makes the state unreachable rather than merely
     overridden, on every table from the cube onward. */
  visibility: hidden;
  transition: opacity 0.3s ease, visibility 0s linear 0.3s;
}

/* THE ENDING NO LONGER FADES. This block dissolved the phone, both backdrops and the intro over 0.9s so
   SKILLS could arrive on a clear card; SKILLS now RISES OVER this one instead (see SLIDE-UP LANDING), so
   the card must stay fully painted right up to the moment it is covered. Nothing sets .is-ended any more —
   endSequence() hands off to the deck directly. */
/* the "here" pointer belongs to the blueprint beat no longer — desktop only; ≤430 keeps its own -m pointer */
.opt-scan__pointer { display: none; }

/* SHOPPING LIST panel (2024:465) — 1200x600 #aef5b7 card, slides in right-to-left as the pill unfurls,
   on the aperture's own curve so the two read as one move. */
.opt-scan__shoplist {
  position: absolute;
  /* Centring moved off the transform and onto a margin so GSAP owns `transform` outright — mixing a CSS
     translateX(-50%) with gsap's xPercent would fight over the same property. Width is fixed at 1200. */
  left: 50%;
  margin-left: -600px;
  /* Anchored ABOVE the nav bar, not centred. Centring a fixed 600px panel while the bar rides
     bottom: 2.47% meant the two closed on each other as the viewport shortened — measured -13px at
     810 and -26px at 780 (overlapping), clearing only at 900. Sharing the bar's own bottom origin
     keeps the gap constant: 2.47% (the float's gap) + 60px (its height) + 24px of clearance. */
  /* THE PERCENTAGE SURVIVES THE ZOOM, THE PIXELS DO NOT. Under zoom the containing block resolves in the
     element's own scaled units, so the 2.47% comes out exact while the 60 + 24 renders at 84 * scale —
     measured 95.6 against the intended 100.5 at 760. Dividing ONLY the px half puts the whole gap back at
     its design value at every height (verified 900 / 810 / 795 / 760 / 700). */
  bottom: calc(2.47% + (60px + 24px) / var(--sl-scale, 1));
  /* SCALED TO THE APERTURE, not resized. See setScale() for the derivation: --sl-scale is 1 wherever the
     600 fits and shrinks only on viewports too short to hold it, so nothing moves at 900+ and the card
     stays whole instead of losing its top edge to overflow:hidden. zoom rather than transform because
     GSAP owns transform here; parkX() carries this scale in its divisor. */
  zoom: var(--sl-scale, 1);
  width: 1200px;
  height: 600px;
  box-sizing: border-box;
  padding: 24px 40px;
  background: #aef5b7;
  border-radius: 4px;
  overflow: hidden;
  display: flex;
  align-items: center;
  z-index: 6;
  /* Parked off-stage at 120% of its own width — past the phone's overflow:hidden edge, so it is hidden
     with JS disabled too. GSAP animates xPercent from here; no CSS transition on transform. */
  /* VIEWPORT-RELATIVE PARK. Was translateX(120%) — a % of the panel's OWN 1200px width, i.e. a constant
     1440px, while the distance needed to clear the right edge is 50vw + 600 and grows with the viewport.
     Break-even was exactly 1680; above it the panel leaked on screen at half the rate of viewport width
     (1728 → 24px, 1920 → 120px, 2560 → 440px, 3440 → 880px, chip-first since the chip is the left column).
     This is the state on screen through MEASURE YOUR SPACE — slPark() only runs later, from cloneShopList()
     — so the stylesheet is what was leaking. calc() re-resolves vw on resize with no JS involved.
     +24 covers the 4px radius and compositor bleed. */
  /* 200vw: deliberately generous rather than exact. This is only the pre-GSAP first-paint park, CSS
     cannot measure the panel's resting position, and the scaled card makes a closed-form offset
     scale-dependent (a (50vw + 624)/scale still leaked 20px at 2560x1440). GSAP's parkX() measures and
     parks precisely from the first tween on; this just has to be off-screen at any width. */
  transform: translateX(200vw);
  opacity: 1;
  pointer-events: none;
}
/* the slide itself is a GSAP tween (see scanPip) — the class only drives the tint and the close gating */
.opt-scan__shoplist-inner { display: flex; align-items: center; gap: 39px; }
.opt-scan__shoplist-col { width: 301px; flex-shrink: 0; display: flex; flex-direction: column;
  align-items: flex-start; gap: 24px; }                       /* 2024:467 */
.opt-scan__shoplist-cards { display: flex; align-items: center; gap: 34px; }   /* 2024:485 */
.opt-scan__shoplist-title { margin: 0; font-family: var(--font-body); font-weight: 400; font-size: 48px;
  line-height: normal; color: #42604D; }                      /* 2024:682 — two lines here, not three */
.opt-scan__shoplist-title span { display: block; }
.opt-scan__shoplist-text { display: flex; flex-direction: column; gap: 24px; }  /* 2030:109 */
/* CTA PAIR (2039:22). Hugs the wider of the two — the pill — and centres the other on it, so the View row
   lands on the button's axis without either carrying a width. The box stays at the column's left edge,
   which is where the node puts the pill (its left ink = the column's left edge exactly). Copy can change
   in either line and the pairing survives, which a matched width would not. */
.opt-scan__shoplist-cta { display: flex; flex-direction: column; align-items: center; gap: 24px;
  width: fit-content; align-self: flex-start; }
.opt-scan__shoplist-sized { margin: 0; font-family: var(--font-ui); font-weight: 300; font-size: 48px;
  line-height: normal; color: #42604D; white-space: nowrap; } /* 2030:113 */
.opt-scan__shoplist-sized span { display: block; }
/* 2039:22 — sentence case, not caps. The comp's label ink measures 147px wide against 157px for this
   string at the shipped 18px, i.e. the comp sits a shade under 18; kept at 18 because that is what
   already ships and matches the Skills label beside it. Only the CASE was wrong. */
.opt-scan__shoplist-view { margin: 0; display: flex; align-items: center; gap: 15px;
  font-family: var(--font-ui); font-weight: 300; font-size: 18px; color: #42604D; white-space: nowrap; }

/* Shared arrow — ONE inlined path (lifted from Button-skills-active.svg) painted with currentColor, so
   the same element serves the green View link, the Skills default (pink arrow on a green label — see the
   override at the pill) and the white Skills active, without a second asset or a swap. Measured 15px wide in BOTH comp CTAs, so one size covers both. */
.opt-scan__shoplist-arrow { width: 15px; height: 12.05px; display: block; flex: 0 0 auto; }

/* 2039:22 default / 2055:49 active — "Skills you'll need".
   CODE EQUIVALENT, not the SVG: the export outlines its text, so the label is live type here and the
   pill is content-sized rather than pinned to 214px. The comp's insets (34 left / 30 right, 14 gap,
   label 122px at 18px) sum to 215 — content-sizing lands on the same box while letting the label reflow.
   The 214x40 export and the in-context comp disagree on label size (151px ink vs 122px): that is the
   standalone component drawn larger, not a state change — the user's spec for active is fill + colour
   only, so BOTH states use 18px and only the paint changes.
   rx 20 on a 40-tall box is a full pill, expressed as a radius that cannot un-round if the height moves. */
.opt-scan__shoplist-skills {
  display: inline-flex; align-items: center; gap: 14px;
  padding: 0 30px 0 34px; height: 40px; border-radius: 999px;
  border: 1px solid #42604D; background: transparent; color: #42604D;
  font-family: var(--font-ui); font-weight: 300; font-size: 18px; line-height: 1;
  white-space: nowrap; align-self: flex-start;
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* DEFAULT (button-skills.svg): dark-green stroke and label, but the arrow stays PINK — the one part that
   does not track the label. currentColor alone cannot express that, so the arrow is coloured explicitly
   here and handed back to `inherit` in the active rule below, where it goes white with everything else.
   Scoped to the skills pill so the identical arrow on the View measurements line is untouched. */
.opt-scan__shoplist-skills .opt-scan__shoplist-arrow { color: #FF3567; transition: color 0.3s ease; }
/* ACTIVE (2055:49) — pink fill, white label AND arrow. The arrow rides `color` with the label, so this
   is a two-property change; no second icon, no crossfade to keep in sync. */
.opt-scan.is-sl-skills .opt-scan__shoplist-skills { background: #FF3567; border-color: #FF3567; color: #FFFFFF; }
.opt-scan.is-sl-skills .opt-scan__shoplist-skills .opt-scan__shoplist-arrow { color: inherit; }
/* 2028:106 is a 48px box = a 24px ICON plus its 24px top/right padding — not a 48px icon. Expressed as
   the inset rather than the box: the glyph is 24px, sitting 24px in from the panel's top and right edges
   (which is where the node's 48 box at left 1151.24 / top 0 puts it inside the 1200-wide panel). */
.opt-scan__shoplist-close { position: absolute; top: 24px; right: 24px; width: 24px; height: 24px;
  padding: 0; border: 0; background: none; cursor: pointer; }
.opt-scan__shoplist-close img { width: 100%; height: 100%; display: block; transition: opacity 0.3s ease; }
/* the pink state (2039:251 -> 2040:257) is the SAME dotted scan mark as the resting icon on a #FF3567
   disc — the node's nine dots are white @0.75, matching icon-close.svg's exactly — so it is a straight
   recolour of the disc and the crossfade changes colour only, with the mark holding still. */
.opt-scan__shoplist-close--on { position: absolute; left: 0; top: 0; opacity: 0; }
/* Gated on the panel being open AS WELL AS the closing beat, so the pink can never paint on arrival even
   if is-sl-close somehow outlives a cycle — the button always enters in its default state. */
.opt-scan.is-sl-panel.is-sl-close .opt-scan__shoplist-close img:not(.opt-scan__shoplist-close--on) { opacity: 0; }
.opt-scan.is-sl-panel.is-sl-close .opt-scan__shoplist-close--on { opacity: 1; }
/* the panel's chip: same .opt-chip shell, cart glyph + "Shopping list" (2024:698 / 2024:696).
   DELIBERATE EXCEPTION — do not fold into .opt-chip__label. "All chip styles are the same" governs the
   SECTION chips (scan/learn/shop/make); this one is a panel chip and Figma gives it its own treatment:
   Bricolage MEDIUM at 14px, not ExtraBold at 15.625. Size and weight both differ, so it is a design
   decision rather than drift. Ruled 2026-08-20. */
.opt-chip--shoplist .opt-chip__icon { width: 35px; height: 30px; }
.opt-chip--shoplist .opt-chip__icon img { width: 35px; height: 30px; }
.opt-chip--shoplist .opt-chip__label { font-family: var(--font-bricolage); font-weight: 500;
  font-variation-settings: 'opsz' 14, 'wdth' 100; font-size: 14px; line-height: 23.438px; }

/* Table-4 (japanese) ONLY — like icon (inlined SVG, so the pill + heart recolor/animate per-part). At rest
   it matches the other bars (green pill, white@75% heart). On the japanese beat (.is-extended on its arrow)
   the pill → dark green, the heart → full white + a thumbnail-style scale-bump (1.055, 0.3s ease about the
   heart's own centre via transform-box:fill-box). All three revert to default when the loop moves away
   (.is-extended drops). Scoped to --japanese so the other three bars (plain <img> like icons) are untouched. */
.opt-scan__nav-like-pill { fill: #5D8568; transition: fill 0.3s ease; }
.opt-scan__nav-like-heart {
  fill: #FFFFFF; fill-opacity: 0.75;
  transform-box: fill-box; transform-origin: center;
  transition: fill-opacity 0.3s ease, transform 0.3s ease;
}
/* ACTIVE — first move of the japanese -> WHAT YOU'LL NEED chain. Was keyed to :has(.is-extended), i.e.
   the beat-2 arrow leg ~2.8s in; it now has its own trigger so it can open the stagger. */
.opt-scan.is-sl-like .opt-scan__nav-like-pill { fill: #42604D; }
.opt-scan.is-sl-like .opt-scan__nav-like-heart { fill-opacity: 1; transform: scale(1.055); }

/* next-arrow — pure code (arrowhead ↔ leg-extended crossfade), no baked dependency. Sits as the
   last flex child inside the right padding, so it clears the aperture's rounded corner. Retracts
   (arrowhead) as each table shows, extends (leg) one beat before the next. */
.opt-scan__nav-arrow {
  position: relative;
  height: 46cqh;
  aspect-ratio: 118 / 96;
  flex-shrink: 0;
}
.opt-scan__nav-arrow img,
.opt-scan__nav-arrow .leg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;        /* matches the inline leg svg's xMidYMid-meet fit */
}
/* chevron arrowhead is persistent (identical in both states); only the leg tail animates. */
/* leg tail columns: opacity-fade per column. Base rule = RETRACT — no delay, so when .is-extended
   is removed every column dissolves out together (no reverse stagger). */
.opt-scan__nav-arrow .leg-col {
  opacity: 0;
  transition: opacity 0.12s ease;   /* compressed from 0.3s so the full 4-col reveal fits the 0.3s scale-bump window */
}
/* EXTEND — reveal sequentially, nearest the arrowhead (col 1, cx 62.95) → furthest (col 4, cx 39.26),
   all inside the is-extended window. Delay applies only on the way IN.
   nth-of-type (NOT nth-child): the float bars bake the arrowhead as ~14 standalone <path> siblings
   ahead of the leg-cols, so nth-child(1-4) missed them entirely (floats revealed all columns at once).
   The only <g> elements in every leg svg are the 4 .leg-col groups, so nth-of-type(1-4) lands on them
   in BOTH the floats and the bottom bar → all four arrows now stagger identically. */
.opt-scan__nav-arrow.is-extended .leg-col { opacity: 1; }
/* Compressed stagger so all 4 columns finish within the 0.3s scale-bump: step 0.06s, per-col fade 0.12s →
   last col (delay 0.18 + 0.12) lands at 0.30s, together with the scale. 60ms step keeps the nearest→
   furthest progression just perceptible (was 0.13s step / 0.3s fade → 0.69s total). */
.opt-scan__nav-arrow.is-extended .leg-col:nth-of-type(1) { transition-delay: 0s; }
.opt-scan__nav-arrow.is-extended .leg-col:nth-of-type(2) { transition-delay: 0.06s; }
.opt-scan__nav-arrow.is-extended .leg-col:nth-of-type(3) { transition-delay: 0.12s; }
.opt-scan__nav-arrow.is-extended .leg-col:nth-of-type(4) { transition-delay: 0.18s; }
/* Table-4 (japanese) ONLY — STAGGER the arrow leg AFTER the heart bump: the heart fires at .is-extended
   (1.4s, 0.3s → done ~1.7s); the leg is delayed +0.3s (base delays + 0.3) so col-1 starts ~1.7s, i.e. once
   the heart completes. Last col lands at 0.3 + 0.18 + 0.12 = 0.60s after the cue (~2.0s) — before the table
   advances at 2.3s. Scoped to --japanese (higher specificity), so the other three bars stay simultaneous.
   Extend-only: the retract (is-extended dropped → base .leg-col, no delay) is unaffected → all dissolve together. */
.opt-scan__nav-float--japanese .opt-scan__nav-arrow.is-extended .leg-col:nth-of-type(1) { transition-delay: 0.3s; }
.opt-scan__nav-float--japanese .opt-scan__nav-arrow.is-extended .leg-col:nth-of-type(2) { transition-delay: 0.36s; }
.opt-scan__nav-float--japanese .opt-scan__nav-arrow.is-extended .leg-col:nth-of-type(3) { transition-delay: 0.42s; }
.opt-scan__nav-float--japanese .opt-scan__nav-arrow.is-extended .leg-col:nth-of-type(4) { transition-delay: 0.48s; }

/* ─── Cube + Round + Chunky FLOATING nav bars — inside the aperture (v=84) ──────────────────────────
   Siblings of .opt-scan__nav inside .opt-scan__phone, floated above the aperture bottom (not pinned).
   Tables 1–3 (cube / round / chunky-legs) now ALL use the same cube-style bar (Figma node 1401:108):
   600×60 rect, rx 8, glass + rim + drop shadow, 30px icon pills, 52px thumb, row padding 4px 0 4px 16px.
   The modifiers (--cube / --round / --chunky) differ ONLY in per-table content (title / NEXT / thumb);
   geometry + content sizing are shared on the base + the breakpoint rules below. Content/arrow are
   #42604D so they read on the bright frost (incl. the NEXT block — mint vanishes). Only table 4
   (japanese) keeps the full-width bottom-pinned in-aperture bar (.opt-scan__nav). Desktop/x-lg only. */
.opt-scan__nav-float {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: 2.47%;                                       /* float gap (≈24px @1920×1080) — TUNABLE */
  /* size is FIXED PER TIER (Option A) via @container on the aperture below — NOT fluid. The old fluid
     width (56.4%/52.5%) + height calc shrank the bar until content was unreadable; fixed sizes keep it
     legible, swapping to the smaller asset only when the aperture's sides reach the bar. Height matches
     the bottom bar at each tier's reference aperture, drift within a band accepted (legibility wins). */
  container-type: size;                                /* re-scales the cqh content to the (fixed) bar height */
  background: rgba(255, 255, 255, 0.12);               /* frost — samples the sharp apt behind it */
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 0.75px solid rgba(255, 255, 255, 0.40);      /* rim */
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.50),           /* top rim highlight */
    0 10px 22px rgba(0, 0, 0, 0.22);                   /* soft drop shadow → floats above the apt */
  opacity: 0;
  /* NO opacity crossfade — the bars HARD-SWAP on table change. All four share identical glass + icons +
     arrow, so crossfading them dipped those to ~75% ("icons animate in with each table"). Hard-swapping
     means the identical parts show no visible change (icons + arrow + glass stay STATIC); only the per-bar
     name/thumb differ (they update on the swap, then do their own per-beat bold/scale). Table IMAGES still
     crossfade (separate `.opt-scan__table` transition). [First-show + collapse are now instant too.] */
  pointer-events: none;
  z-index: 2;
}
/* SKIP the float bars' contents until the tables beat — kills a first-loop-only render spike at expand.
   The bars are opacity 0 for the whole expand→measure→pointer run (~5.7s), but their subtree was still
   being re-laid-out every time the `@container aperture` queries re-evaluated: .opt-scan__phone animates
   368px → 1346px over the 0.6s expand, crossing BOTH breakpoints (696 and 468), and each crossing
   re-lays-out four bars whose own content is cqh-sized off `container-type: size`. Traced at 1440×900,
   worst single Layout pass over the expand: 6.1ms as-shipped vs 0.7ms with the bar contents skipped —
   the spike lands ~26ms after expand() and has no counterpart on later loops (the phone is already at
   full size, so no container re-evaluation). Deliberately NOT fixed by dropping `container-type` from
   the aperture: that measured worse (1.7ms) AND breaks the tier rules the bars need once visible.
   Released on .is-tables, which JS adds 500ms (desktop) / 750ms (mobile) BEFORE startTables() begins the
   inaugural fade — so the subtree is laid out well before any opacity/transform transition starts.
   (content-visibility is not animatable; the gap is what keeps the inaugural feather clean.) The bars
   already carry `container-type: size` + fixed per-tier width/height, so the size containment that
   content-visibility implies changes nothing about their geometry. */
.opt-scan__nav-float { content-visibility: hidden; }
.opt-scan.is-tables .opt-scan__nav-float { content-visibility: visible; }
/* Floats (tables 1-3) right side: NEXT label + name = #42604D @75% — mint washes out on the bright frost.
   The bottom bar (table 4) keeps the base mint #E5F8E8. Arrow stays #42604D (inline paths use currentColor). */
.opt-scan__nav-float .opt-scan__nav-arrow { color: #42604D; }
/* ── Fixed size per tier, swapped by @container on the aperture width (`.opt-scan__phone`) ──────────
   All three floats share ONE cube-style geometry (Figma 1401:108) at every tier, so the cube↔round↔
   chunky crossfade is pure opacity with NO size jump. Breakpoints = bar-width + ~48px each side:
   swap when the aperture narrows to 440 + 96 = 536, then to the MOBILE pill at < 468.
     all floats : 440 → (ap< 536) → 372 → (ap< 468) → 289                                              */
.opt-scan__nav-float--cube,
.opt-scan__nav-float--round,
.opt-scan__nav-float--chunky,
.opt-scan__nav-float--japanese,
.opt-scan__nav-float--category { width: 360px; height: 60px; border-radius: 8px; }
/* Base width = Figma's 360. Measured intrinsic content at this 60px tier: cube 315, round 340, chunky 326,
   japanese 316; plus the row's 16px left inset (the arrow carries its own right inset) the widest resting
   bar needs 356, so 360 fits them all. The bar is NOT fixed beyond that: when the "What you'll need" pill
   unfurls the japanese bar grows with it (below). NB the bars are container-type:size, so the width has to
   stay definite — a max-content bar would collapse under size containment, hence two explicit values. */
/* Japanese only — the bar tracks the pill. Content reaches 406 with the pill open, + 16 inset = 422 (Figma
   ~407 against its shorter 47.8px bar; ours is 60px tall with cqh-scaled content). The float is centred by
   left:50% + translateX(-50%), so growing the width opens it symmetrically left AND right. The 0.5s curve
   matches the pill's own width transition so bar and button move as one. */
.opt-scan__nav-float--japanese { transition: opacity 0s, width 0.5s cubic-bezier(0.22, 1, 0.36, 1); }
.opt-scan.is-sl-open .opt-scan__nav-float--japanese { width: 426px; }   /* 422 measured exactly flush — 4px cushion */
/* ALL FOUR floats now share the base glass finish — white stroke (0.75px rgba(255,255,255,0.40)) + inner
   shading (inset 0 1px 0 rgba(255,255,255,0.50)) + drop shadow — set on .opt-scan__nav-float above. (The
   earlier --chunky/--japanese border:none/box-shadow:none plain-glass override has been removed.) */
/* Fixed-px content sizing — floats only (the row padding/insets are now shared on .opt-scan__nav-row so
   the bottom bar matches; only the float's exact px CONTENT sizing stays float-scoped — the bottom bar
   keeps its taller cqh content). Node 1401:108: thumb h-52 fills the 52px box; icons size-30 pills. */
.opt-scan__nav-float .opt-scan__nav-check { width: 24px; height: 24px; }   /* node 2028:48 — size-24 */
.opt-scan__nav-float .opt-scan__nav-icon  { height: 30px; }              /* node icons size-30 pills (#5d8568) */
@container aperture (width < 456px) {                                   /* all floats: 360 + 2×48 → X-sml pill */
  .opt-scan__nav-float--cube,
  .opt-scan__nav-float--round,
  .opt-scan__nav-float--chunky,
  .opt-scan__nav-float--japanese,
.opt-scan__nav-float--category { width: 360px; height: 48px; border-radius: 16px; }
}
@container aperture (width < 468px) {                                   /* all floats: 372 + 2×48 → MOBILE pill */
  .opt-scan__nav-float--cube,
  .opt-scan__nav-float--round,
  .opt-scan__nav-float--chunky,
  .opt-scan__nav-float--japanese,
.opt-scan__nav-float--category { width: 289px; height: 38px; border-radius: 8px; }
}
/* PRE-STATE — .is-tables is on but NO table has appeared: showTable() sets exactly one of the four float
   classes the instant the first table arrives, so "no float class yet" IS the pre-pick window. The category
   bar owns it. This keys off the TABLE's own appearance (what the design ties the pick to), not the nav
   bar's .is-first-table, so nothing here touches the nav bar's inaugural timing. */
.opt-scan.is-tables:not(.is-cube-float):not(.is-round-float):not(.is-chunky-float):not(.is-japanese-float)
  .opt-scan__nav-float--category { opacity: 1; }
/* Each category icon stacks its two baked states; only the crossfade lives in CSS. During the pause the
   section carries .is-cat-1..4 in turn (JS), and the pick re-highlights coffee via .is-category-picked. */
.opt-scan__nav-cat { position: relative; display: block; }
.opt-scan__nav-cat-on { position: absolute; left: 0; top: 0; opacity: 0; }
.opt-scan__nav-cat-rest, .opt-scan__nav-cat-on { transition: opacity 0.3s ease; }
.opt-scan.is-cat-1 .opt-scan__nav-cat--shelves .opt-scan__nav-cat-rest,
.opt-scan.is-cat-2 .opt-scan__nav-cat--bookcase .opt-scan__nav-cat-rest,
.opt-scan.is-cat-3 .opt-scan__nav-cat--video .opt-scan__nav-cat-rest,
.opt-scan.is-cat-4 .opt-scan__nav-cat--coffee .opt-scan__nav-cat-rest,
.opt-scan.is-category-picked .opt-scan__nav-cat--coffee .opt-scan__nav-cat-rest { opacity: 0; }
.opt-scan.is-cat-1 .opt-scan__nav-cat--shelves .opt-scan__nav-cat-on,
.opt-scan.is-cat-2 .opt-scan__nav-cat--bookcase .opt-scan__nav-cat-on,
.opt-scan.is-cat-3 .opt-scan__nav-cat--video .opt-scan__nav-cat-on,
.opt-scan.is-cat-4 .opt-scan__nav-cat--coffee .opt-scan__nav-cat-on,
.opt-scan.is-category-picked .opt-scan__nav-cat--coffee .opt-scan__nav-cat-on { opacity: 1; }

/* the prompt field's label follows the highlighted icon; "CHOOSE A CATEGORY" is the resting text. Hard
   swap (display), matching the float bars' own hard-swap idiom — no crossfade between labels. */
.opt-scan__nav-cat-lbl { display: none; }
.opt-scan__nav-cat-lbl--none { display: block; }
/* the ACTIVE category reads louder than the resting prompt: 700 is this bar's own emphasis step (the NEXT
   name bolds 500 -> 700 on its beat), and the size lifts a fifth with it. `em` rather than a fixed value
   or cqh so it steps off whatever the tier hands down — 17cqh on desktop, the re-fixed 13px at <=430 —
   instead of shrinking the label on mobile. "CHOOSE A CATEGORY" keeps the base treatment. */
.opt-scan__nav-cat-lbl--coffee,
.opt-scan__nav-cat-lbl--shelves,
.opt-scan__nav-cat-lbl--bookcase,
.opt-scan__nav-cat-lbl--video { font-size: 1.2em; font-weight: 700; }
.opt-scan.is-cat-1 .opt-scan__nav-cat-lbl--shelves,
.opt-scan.is-cat-2 .opt-scan__nav-cat-lbl--bookcase,
.opt-scan.is-cat-3 .opt-scan__nav-cat-lbl--video,
.opt-scan.is-cat-4 .opt-scan__nav-cat-lbl--coffee,
.opt-scan.is-category-picked .opt-scan__nav-cat-lbl--coffee { display: block; }
.opt-scan.is-cat-1 .opt-scan__nav-cat-lbl--none,
.opt-scan.is-cat-2 .opt-scan__nav-cat-lbl--none,
.opt-scan.is-cat-3 .opt-scan__nav-cat-lbl--none,
.opt-scan.is-cat-4 .opt-scan__nav-cat-lbl--none,
.opt-scan.is-category-picked .opt-scan__nav-cat-lbl--none { display: none; }
/* the category bar stays up across the pick so the dark icon is actually seen, then goes as the table lands */
.opt-scan.is-category-picked:not(.is-cube-float):not(.is-round-float):not(.is-chunky-float):not(.is-japanese-float)
  .opt-scan__nav-float--category { opacity: 1; }

/* show the matching float per state (all four tables are floats now — no bottom bar to hide) */
.opt-scan.is-tables.is-cube-float     .opt-scan__nav-float--cube     { opacity: 1; }
.opt-scan.is-tables.is-round-float    .opt-scan__nav-float--round    { opacity: 1; }
.opt-scan.is-tables.is-chunky-float   .opt-scan__nav-float--chunky   { opacity: 1; }
.opt-scan.is-tables.is-japanese-float .opt-scan__nav-float--japanese { opacity: 1; }
/* INAUGURAL reveal only: the first bar fades in WITH the first table as one beat — matched 1.2s ease-in-out
   (same as .opt-scan__table.is-active). The timed .is-first-table is dropped after the fade (JS), so
   subsequent table-to-table bar changes keep their instant hard-swap (no crossfade dip). */
.opt-scan.is-first-table .opt-scan__nav-float { transition: opacity 1.2s ease-in-out; }

/* in-phone chrome over the clear video — dissolves as the aperture widens, reverses
   on close. The close button stays pointer-interactive at opacity 0 so it can still
   trigger the collapse/reverse. */
.opt-scan__chrome {
  position: absolute;
  inset: 0;
  pointer-events: none;
  transition: opacity 0.4s ease;
}
.opt-scan__chrome button { pointer-events: auto; }
.opt-scan.is-expanded .opt-scan__chrome { opacity: 0; }

/* Matey mark — top-left; recolored to #42604D via mask (source SVG is mint) */
.opt-scan__mark {
  position: absolute;
  left: 20px;
  top: 20px;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.opt-scan__mark-glyph {
  width: 31px;
  height: 32px;
  background-color: #42604D;
  -webkit-mask: url(../../Assets/SVGs/logo/matey-mark.svg) no-repeat center / contain;
  mask: url(../../Assets/SVGs/logo/matey-mark.svg) no-repeat center / contain;
}

/* close button (1164:1001) — Figma Icon-close-dots provides the circle + dotted X;
   button is just the 36px frame at top 16 / right 17 */
.opt-scan__close {
  position: absolute;
  top: 16px;
  right: 17px;
  width: 36px;
  height: 36px;
  border: none;
  background: none;
  padding: 0;
  cursor: pointer;
}
.opt-scan__close img { display: block; width: 100%; height: 100%; }

/* "Ask me anything…" prompt pill — glassy white (Figma Frame 48098098).
   Figma Glass (frost 16 / refraction 80 / depth 20 / dispersion 50 / light −45°/80%)
   has no CSS export; approximated with the project glass idiom (hero waitlist card):
   white ~50% fill + backdrop frost/saturate + the spec's 0/0/24 #0A172B inner shadow
   + a top-light rim (light −45°) and a soft lift. */
.opt-scan__ask {
  position: absolute;
  left: 16px;
  right: 16px;
  bottom: 18px;
  height: 44px;
  padding: 0 16px 0 4px;   /* left 4px to match Figma 1335:1712 (pl-[4px]) */
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.5);
  border: 1px solid rgba(255, 255, 255, 0.45);
  backdrop-filter: blur(16px) saturate(140%);
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  box-shadow:
    inset 0 0 24px rgba(10, 23, 43, 0.30),
    inset 0 1px 1px rgba(255, 255, 255, 0.55),
    0 8px 24px rgba(10, 23, 43, 0.12);
  display: flex;
  align-items: center;
  gap: 8px;   /* icon→text — Figma 1335:1712 is gap-[4px]; +4px per design tweak */
  opacity: 1;
  transition: opacity 0.15s ease;   /* fast fade-out at expand start — see .is-expanded rule below */
}

/* Hide the prompt field FAST at the very start of the expand — the SAME .is-expanded trigger that
   begins the 0.6s aperture widen. Freeze its width (right:auto + fixed rest width 336px = phone 368 −
   2×16 inset) so it can't stretch with the growing container; at the freeze instant the box is identical
   to its rest size (no jump), and the 0.15s fade clears it well before the width visibly moves. The old
   path let the chrome's 0.4s fade run while the field — anchored left:16/right:16 — stretched with the
   widening phone, so the stretch was visible. Reverses on collapse (back to responsive inset, fades in). */
.opt-scan.is-expanded .opt-scan__ask {
  opacity: 0;
  width: 336px;
  right: auto;
}

/* prompt icon — Icon-prompt.svg (two rings of #42604D dots). SVG is tight-cropped to its
   content (viewBox 8.264 7.719 23.473 24.559) so the dots sit flush to the icon box edge —
   no internal whitespace pushing them right of the field's padding (Figma 1335:1719). */
.opt-scan__ask-icon {
  flex-shrink: 0;
  width: 23.473px;
  height: 24.559px;
  display: block;
}
.opt-scan__ask-text {
  font-family: var(--font-noto);
  font-weight: 400;
  font-size: 13px;
  line-height: 24px;
  color: #42604D;
  white-space: nowrap;
}

/* ════════════════════════════════════════
   SHOPPING LIST  (Figma 1178:1276 — static app shop screen)
   Card text maps Figma Helvetica Neue / Lexend -> Nunito (loaded set).
════════════════════════════════════════ */
.opt-shop {
  position: relative;
  height: 800px;
  background: var(--color-green-card);
  overflow: hidden;
}

/* bright-mint shop card */
.opt-shop__card {
  position: absolute;
  left: 50%;
  top: 59.5px;
  transform: translateX(-50%);
  width: 1200px;
  height: 600px;
  background: var(--color-green-accent);
  overflow: hidden;
  /* Figma layout row: heading + cards group, left-aligned, vertically centred (content frame px-40 py-24) */
  display: flex;
  align-items: center;
  gap: 72px;
  padding: 24px 40px;
}

/* PLAN chip stacked above the carousel heading — present in BOTH states so the heading
   doesn't shift on toggle; head width = title width (301px) preserves the cross-state card lock. */
.opt-shop__head {
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 24px;                 /* PLAN chip → heading */
  min-height: 320px;         /* head-height lock (v=207) — companion to the v=145 .opt-shop__cards
                                min-height:506 lock. .opt-shop__card is align-items:center, centring the
                                head and cards group independently; the head is NOT cards-height-locked, so
                                state-1's 2-line title (head 253.66) vs state-2's 3-line title (head 319.66)
                                centred the two heads 33px apart → chip+title y-jumped on toggle. Locking the
                                head to the taller (state-2 ≈319.66) box equalises both → chip.top & title.top
                                match across states, zero y-jump. Head-only: cards group + L (width:301) unaffected. */
}

.opt-shop__title {
  flex-shrink: 0;
  margin: 0;
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 48px;
  line-height: normal;
  color: #42604d;
  white-space: nowrap;
  width: 301px;            /* lock width across states so the cards group can't slide on switch
                              (state-1 "YOU'LL NEED" ≈301 is the wider; state-2 "AND BUILD" ≈260 fits) → cards align L=525.5 both */
}

/* the two cards (shopping + sustainable) sit side by side, equal height */
.opt-shop__cards {
  display: flex;
  gap: 34px;
  align-items: stretch;
  min-height: 506px;       /* equalise the two card-groups' height (state-1 505.4 vs state-2 492.0) so the
                              parent's align-items:center can't y-shift the group by 6.7px on state switch */
}
/* DESKTOP track = the two cards side-by-side (v=249 regression fix). The v=241 carousel work
   inserted .opt-shop__track between the WYN (.opt-shop__cards) and the two cards, but only styled
   the track inside @media(≤430). On desktop the track fell back to display:block → the cards
   (panel + sustain) stacked vertically (portrait). Restore the row layout the WYN's direct
   children used to get; gap matches the old direct-child gaps (34 state-1, 45 state-2 below).
   ≤430 re-flexes this track as the translating carousel (gap:16, width:max-content). */
.opt-shop__track { display: flex; gap: 34px; align-items: stretch; }
.opt-shop__title span { display: block; }

/* Both states show the PLAN chip above the title (identical .opt-chip--plan markup + .opt-shop__head
   flex-column gap:24px), so the icon+heading sit at the same offset/alignment/spacing in state 1 and
   state 2. (Prior to v=206 state-1's chip was visibility:hidden; its box was already reserved, so
   revealing it is a paint-only change — the v=145 cross-state lock at L=525.5 is unaffected.) */

/* list panel */
.opt-shop__panel {
  flex-shrink: 0;
  width: 322px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* budget header */
.opt-shop__budget {
  width: 322px;
  padding: 8px 16px;
  border-radius: 8px;
  background: #42604d;
  border: 1px solid rgba(255, 255, 255, 0.12);
  display: flex;
  align-items: center;
  gap: 8px;
}
.opt-shop__thumb {
  flex-shrink: 0;
  width: 136.85px;
  height: 71.4px;
  border-radius: 6.8px;
  object-fit: cover;
}
.opt-shop__budget-info {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.opt-shop__budget-top {
  display: flex;
  align-items: center;
  gap: 8px;
}
.opt-shop__budget-pill {
  position: relative;
  width: 92.8px;
  height: 32px;
  padding-left: 6px;
  border-radius: 24px;
  background: #fff;
  overflow: hidden;
  display: flex;
  align-items: center;
  gap: 3px;
  font-family: var(--font-body);
  font-size: 12px;
}
.opt-shop__budget-pill::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 38.4px;
  height: 100%;
  background: rgba(93, 133, 104, 0.25);
}
.opt-shop__budget-pill span { position: relative; }
.opt-shop__budget-pill .k { color: #3f6b88; }
.opt-shop__budget-pill .v { color: #42604d; }
.opt-shop__co2-chip { flex-shrink: 0; width: 32px; height: 32px; display: block; }
.opt-shop__co2-cap {
  font-family: var(--font-body);
  font-size: 10px;
  color: #ebfded;
  white-space: nowrap;
}
/* Desktop SHOP state-1 only (scoped to the .opt-shop__panel markup — the ≤430 card-1 template keeps the
   base 10px). Figma budget-header caption enlarged to 6.25px; the "2" reads just under it at 6px. Code↔Figma
   factor = 10 / 5.34 (current 10px source == old Figma 5.34px), so 6.25→11.7px and 6→11.24px. */
.opt-shop__co2-cap--s1 { font-size: 11.7px; }
.opt-shop__co2-cap--s1 .opt-shop__co2-sub { font-size: 9px; vertical-align: -0.3em; }   /* proper subscript: shrunk from caption size so it seats clearly below the baseline (caption line stays 11.7px) */

/* body (materials list) */
.opt-shop__body {
  width: 322px;
  padding: 4px 8px;
  border-radius: 4px;
  background: #ebfded;
  display: flex;
  flex-direction: column;
}
.opt-shop__rows {
  display: flex;
  gap: 20px;
  padding: 8px 0 8px 8px;
  font-family: var(--font-body);
  font-size: 14px;
  color: #3f6b88;
}
.opt-shop__rows .names,
.opt-shop__rows .prices,
.opt-shop__urows .names,
.opt-shop__urows .prices {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.opt-shop__rows .prices,
.opt-shop__urows .prices { margin-left: auto; text-align: right; }
.opt-shop__rows p,
.opt-shop__urows p {
  height: 22px;
  display: flex;
  align-items: center;
  line-height: normal;
}
.opt-shop__rows .prices p,
.opt-shop__urows .prices p { justify-content: flex-end; }

.opt-shop__buy {
  width: 100%;
  height: 36px;              /* Figma SHOP-1 1717:928 — BUY button 36px tall */
  border-radius: 24px;
  background: #42604d;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  color: #fff;
  font-family: var(--font-body);
  font-size: 14px;
}
.opt-shop__buy .price { font-family: 'Lexend', sans-serif; font-weight: 300; }

/* upgrade block */
.opt-shop__upgrade {
  width: 322px;
  padding: 4px 8px;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.75);
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.opt-shop__add {
  width: 100%;
  height: 36px;              /* Figma SHOP-1 1717:936 — +Add upgrade 36px tall (matches BUY) */
  border-radius: 24px;
  background: var(--color-green-accent);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-body);
  font-size: 14px;
  color: rgba(30, 46, 41, 0.75);
}
.opt-shop__urows {
  display: flex;
  gap: 70px;
  padding-left: 0;
  font-family: var(--font-body);
  font-size: 14px;
  color: #3f6b88;
}

/* sustainable card (right column) — flex item, stretched to the shopping card's height (Figma equal-height) */
.opt-shop__sustain {
  flex-shrink: 0;
  width: 322px;
  padding: 8px 16px;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.75);   /* updated Figma 1453:580 — light card (was #5d8568) */
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.opt-shop__sustain-hero {
  height: 157px;
  border-radius: 4px;
  background: #5d8568;                      /* updated 1453:584 — was #42604d */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
}
.opt-shop__sustain-logo { height: 18px; width: auto; display: block; }
.opt-shop__sustain-cloud { width: 40px; height: 40px; display: block; }
.opt-shop__sustain-tag {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 12px;
  color: #e6f8e9;
}
.opt-shop__sustain-save {
  height: 108px;
  border-radius: 8px;
  background: #d6e1d9;                      /* updated 1453:770 — was rgba(255,255,255,0.75) */
  border: 1px solid rgba(255, 255, 255, 0.12);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-family: var(--font-body);
  font-weight: 600;
  color: #3f6b88;
  text-align: center;
}
.opt-shop__sustain-save .cap { font-size: 12px; }
.opt-shop__sustain-save .kg { font-size: 24px; }
.opt-shop__sustain-rows { display: flex; flex-direction: column; }
.opt-shop__sustain-row {
  height: 48px;
  display: flex;
  align-items: center;
  gap: 11px;
}
/* row icons use dark-#42604d copies (…-dk.svg) so they read on the now-light card */
.opt-shop__sustain-row .ic {
  flex-shrink: 0;
  width: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.opt-shop__sustain-row .ic img { display: block; }
.opt-shop__sustain-row .lbl {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 12px;
  color: #3F6B88;                          /* batch: impact-row labels #42604d→#3F6B88 (overrides locked enum; icons stay #42604d) */
}

/* control bar (below the card) */
.opt-shop__controls {
  position: absolute;
  top: 698px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 4px;
}
.opt-shop__ctrl {
  width: 36px;               /* SHOP-1 bottom controls — 36px (up from 30) */
  height: 36px;
  border-radius: 6px;
  background: #5d8568;
  display: flex;
  align-items: center;
  justify-content: center;
}
.opt-shop__ctrl img { width: 19.8px; height: 17px; display: block; }   /* explicit w/h — these SVGs (viewBox 35×30, no width attr) default to 300px under width:auto, blowing out the row */
.opt-shop__ctrl-main {
  width: fit-content;
  box-sizing: border-box;    /* SHOP-1 Shop pill — 36px tall (up from 30), hugged width */
  height: 36px;
  padding: 0 11px;           /* content inset ~11px each side */
  border-radius: 18px;       /* pill — half of 36 */
  background: #5d8568;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 2.26px;               /* Figma 1717:1041 — cart → label */
}
/* Both control pills (state-1 "What you'll need..." + state-2 "How to Make it...") now share the SAME
   node treatment above — the earlier state-2-only gap/padding override was removed for consistency. */
.opt-shop__ctrl-main img { width: 25.7px; height: 22px; display: block; }   /* cart (Figma 1717:1042 ~22.6px; SVG 35×30 → w25.7 keeps aspect) — explicit w avoids the 300px width:auto default */
.opt-shop__ctrl-main span {
  font-family: var(--font-body);
  font-weight: 500;          /* Nunito Medium */
  font-size: 12px;           /* Figma 1717:1045 — 12.052px label */
  color: #fff;
  white-space: nowrap;
}
.opt-shop__ctrl-lbl--m { display: none; }   /* ≤430-only --pb label "Plan how to make it..." (Figma 1765:1280); desktop --pb keeps "How to Make it..." (.opt-shop__ctrl-lbl) */
/* difficulty bars — Figma 1717:1037-1039 (group 22.6×16.9; bars w5.86, h 3.95/10.17/15.13) */
.opt-shop__bars { display: flex; gap: 1px; align-items: flex-end; height: 17px; }
.opt-shop__bars i { width: 5.86px; border-radius: 1.5px; }
.opt-shop__bars i:nth-child(1) { height: 3.95px; background: rgba(255, 255, 255, 0.75); }
.opt-shop__bars i:nth-child(2) { height: 10.17px; border: 0.75px solid rgba(255, 255, 255, 0.75); }
.opt-shop__bars i:nth-child(3) { height: 15.13px; border: 0.75px solid rgba(255, 255, 255, 0.75); }

/* ════════════════════════════════════════
   SHOP — carousel STATE 2 "PLAN & BUILD" (Figma 1176-1210)
   Toggled by clicking the selection (control) bar.
════════════════════════════════════════ */
/* toggle: state 1 visible by default; .is-state2 swaps to state 2.
   Both cards (absolute @top:59.5) and both control bars (absolute @top:698) are stacked in the same spot,
   so the states CROSS-FADE via opacity (was an instant display swap). The hidden state gets
   pointer-events:none so clicks reach the visible one. Duration + easing match the pager fill below, so the
   cards and the progress bar move together as one eased motion. */
.opt-shop__card,
.opt-shop__controls { transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1); }
.opt-shop__card--pb,
.opt-shop__controls--pb { opacity: 0; pointer-events: none; }
.opt-shop.is-state2 .opt-shop__card:not(.opt-shop__card--pb),
.opt-shop.is-state2 .opt-shop__controls:not(.opt-shop__controls--pb) { opacity: 0; pointer-events: none; }
.opt-shop.is-state2 .opt-shop__card--pb,
.opt-shop.is-state2 .opt-shop__controls--pb { opacity: 1; pointer-events: auto; }

/* hover lift — applies to whichever card is the visible state (the hidden one has pointer-events:none,
   so only the active state-1 / state-2 card responds). Re-declares the transition to add transform +
   box-shadow alongside the 0.6s opacity cross-fade; the lift transform keeps the translateX(-50%) lock. */
.opt-shop__card {
  transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1),
              transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
              box-shadow 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.opt-shop__card:hover {
  transform: translateX(-50%) translateY(-8px);
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.22);
}
@media (prefers-reduced-motion: reduce) {
  .opt-shop__card:hover { transform: translateX(-50%); }   /* no lift; keep the centering lock */
}

/* progress pill / state selector — the manual toggle (persistent across both states).
   Figma: track #ebfded 183.48×8 rounded 24 (1452:270); fill #5d8568 = left half (1452:271),
   moves to the right half in state 2 (1461:1797). Sits at the bottom of the content card. */
.opt-shop__pager {
  position: absolute;
  left: 50%;
  top: 624px;                 /* 59.5 card-top + 572.55 Figma y ≈ 632 bar centre */
  transform: translateX(-50%);
  padding: 8px 12px;          /* enlarges the hit area around the 8px bar */
  background: none;
  border: 0;
  cursor: pointer;
  z-index: 2;
}
.opt-shop__pager-bar {
  display: block;
  position: relative;
  width: 183.479px;
  height: 8px;
  border-radius: 24px;
  background: #ebfded;
}
.opt-shop__pager-bar::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 91.74px;             /* exactly half the track */
  height: 100%;
  border-radius: 24px;
  background: #5d8568;
  transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1); /* synced with the card cross-fade — fill eases L↔R as the states change */
}
.opt-shop.is-state2 .opt-shop__pager-bar::before { left: calc(100% - 91.74px); }
/* progress dots — desktop keeps the pill bar; ≤430 SHOP-1 swaps to 4 dots (rule in the ≤430 block) */
.opt-shop__dots { display: none; }

.opt-shop__cards--pb { gap: 45px; }         /* Figma 1461:1488 inter-card gap (now between WYN and its single track child) */
.opt-shop__cards--pb .opt-shop__track { gap: 45px; }   /* v=249: the 45px inter-card gap now lives on the state-2 track (the two cards are its children, not the WYN's) */
.opt-shop__ctrl-play { display: flex; align-items: center; justify-content: center; width: 16.3px; height: 14px; color: #fff; }
.opt-shop__ctrl-play svg { width: 14px; height: 14px; display: block; }

/* ── card 1 · Plan (light) ── */
.pb-plan {
  flex-shrink: 0;
  width: 322px;
  background: rgba(255, 255, 255, 0.25);
  border-radius: 16px;
  padding: 8px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}
/* updated 1466:2391 — video + caption now sit in a dark-green box; title moved below the video */
.pb-plan__media { background: #5d8568; border-radius: 4px; padding: 16px 16px 0; display: flex; flex-direction: column; }
.pb-plan__caption { display: flex; align-items: center; height: 24px; font-family: var(--font-body); font-size: 12px; color: #f0fdf1; }
.pb-plan__video { position: relative; height: 107px; border-radius: 8px; overflow: hidden; }
.pb-plan__video img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pb-plan__play { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 45px; height: 45px; }
.pb-plan__play svg { width: 100%; height: 100%; display: block; }

.pb-plan__steps {
  background: #f0fdf1;
  border-radius: 8px;
  padding: 8px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.pb-plan__row { display: flex; align-items: center; height: 40px; }
.pb-plan__row--head { justify-content: space-between; padding-left: 8px; }
.pb-plan__head-lbl { font-family: var(--font-body); font-size: 18px; color: #2d690d; }
.pb-num { width: 40px; flex-shrink: 0; text-align: center; font-family: var(--font-body); font-size: 14px; color: #799bae; }
.pb-step-lbl { flex: 1; font-family: var(--font-body); font-size: 14px; color: #799bae; }
.pb-check { display: flex; align-items: center; justify-content: center; color: #799bae; }
.pb-check--green { color: #2d690d; position: relative; }
/* #C2F7C9 badge behind the Plan check only — 24px circle (ref Icon-checkmark_g.svg: glyph ≈ half the circle).
   ::before is out of flow → 0 row shift; glyph stays 18px so all three checks read as a set. NEW literal #C2F7C9. */
.pb-check--green::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 28px;
  height: 28px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: #C2F7C9;
  z-index: 0;
}
/* circle grown 24→28px for breathing room around the chunky stroked glyph (ref Icon-checkmark_g.svg);
   glyph stays 18px = identical to 01/02, ::before out of flow → 0 row shift */
.pb-check--green svg { position: relative; z-index: 1; }
.pb-check svg { width: 18px; height: 18px; display: block; }
.pb-check svg path,
.pb-chevron svg path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

.pb-build {
  background: rgba(255, 255, 255, 0.75);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 16px;
  padding: 8px 8px 8px 16px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  justify-content: center;
}
.pb-build__head { font-family: var(--font-body); font-size: 18px; color: #2d690d; padding: 6px 0; }   /* match the "Plan" panel header (.pb-plan__head-lbl 18px, default weight) */
.pb-build__next { display: flex; align-items: center; justify-content: space-between; background: #fff; border-radius: 24px; padding-left: 16px; height: 40px; }
.pb-build__next-lbl { font-family: var(--font-body); font-size: 14px; color: rgba(45, 105, 13, 0.85); }
.pb-begin { display: flex; align-items: center; background: var(--color-green-accent); border-radius: 24px; padding: 0 16px; height: 40px; font-family: var(--font-body); font-weight: 500; font-size: 14px; color: rgba(30, 46, 41, 0.75); }
.pb-build__complete { display: flex; align-items: center; justify-content: space-between; background: #f0fdf1; border-radius: 24px; padding: 0 8px 0 16px; height: 40px; }
.pb-build__complete-lbl { font-family: var(--font-body); font-size: 14px; color: #799bae; }
.pb-chevron { display: flex; align-items: center; justify-content: center; color: #799bae; }
.pb-chevron svg { width: 24px; height: 24px; display: block; }

/* ── card 2 · Build (dark green) ── */
.pb-build2 {
  flex-shrink: 0;
  width: 322px;
  background: #5d8568;
  border-radius: 16px;
  padding: 16px 16px 24px;
  display: flex;
  flex-direction: column;
  gap: 24px;
}
.pb-build2__title { display: flex; align-items: center; justify-content: space-between; }
.pb-build2__title-lbl { font-family: var(--font-body); font-weight: 500; font-size: 20px; color: #fff; }
.pb-build2__bars { height: 30px; }
.pb-build2__pill { display: flex; align-items: center; justify-content: space-between; height: 40px; padding: 0 8px 0 16px; border-radius: 24px; background: rgba(255, 255, 255, 0.75); }
.pb-build2__pill-lbl { font-family: var(--font-body); font-weight: 400; font-size: 14px; color: rgba(30, 46, 41, 0.75); }  /* batch item 6: --color-black @ 75% (rgba alpha, matching prior 85%); card-1 next-lbl left unchanged */

.pb-build2__steps { display: flex; flex-direction: column; }
.pb-step { display: flex; align-items: center; gap: 4px; height: 45px; border-radius: 16px; }
.pb-step--active { border: 1px dashed rgba(255, 255, 255, 0.85); }
.pb-step__num { width: 45px; flex-shrink: 0; text-align: center; font-family: var(--font-body); font-size: 14px; color: rgba(255, 255, 255, 0.75); }
.pb-step__desc { flex: 1; display: flex; flex-direction: column; justify-content: center; font-family: var(--font-body); font-size: 14px; color: rgba(255, 255, 255, 0.75); line-height: 1.15; }
.pb-step__desc b { font-weight: 400; color: #fff; }
.pb-step__desc i { font-style: normal; color: rgba(255, 255, 255, 0.75); }
.pb-step__play { flex-shrink: 0; width: 45px; height: 45px; display: flex; align-items: center; justify-content: center; }
.pb-step__play svg { width: 24px; height: 24px; display: block; }

.pb-build2__complete { display: flex; align-items: center; justify-content: space-between; height: 40px; padding: 0 8px 0 16px; border-radius: 24px; background: rgba(255, 255, 255, 0.75); }
.pb-build2__complete-lbl { font-family: var(--font-body); font-size: 14px; color: #799bae; }
.pb-chevron--circle { width: 24px; height: 24px; border-radius: 50%; background: rgba(121, 155, 174, 0.2); }
.pb-chevron--circle svg { width: 16px; height: 16px; }
/* mortise pill toggle: real asset (white square + #799BAE up/down chevrons) per node 1461:1699 "Icon-expand" */
.pb-build2__expand { flex-shrink: 0; width: 24px; height: 24px; display: block; }

/* ════════════════════════════════════════
   FOOTER  (Figma 1148:641 — static)
════════════════════════════════════════ */
/* ════════════════════════════════════════
   SKILLS — Inspiration feed (Figma 1485:1029 — static)
   Title + filter sidebar + clipped 2-col photo grid. Sits between SHOP and JOIN.
   Recurring literals (no token): #3f6b88, #799bae, #f0fdf1, #42604d, white-alpha overlays.
════════════════════════════════════════ */
.opt-skills {
  background: var(--color-green-accent);   /* swapped with panel — vivid frame */
  padding: 48px 0 0;         /* node 1485:1029 — 0 bottom padding (was 48px); top stays 48 */
  overflow: hidden;          /* clip the title/head when it scrolls away on the down leg */
}
.opt-skills__inner {
  position: relative;       /* module rise uses top: (not transform) so the nested photo-transform
                               follows it correctly on iOS Safari (no ancestor-transform conflict) */
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}
.opt-skills__head {
  display: flex;
  align-items: center;
  gap: 16px;
}
/* 1959:338 — "Learn the skills you'll need to make it." Bricolage Grotesque Medium, matching where the
   SCAN titles landed. Measured against the comp: its title inks 863px wide; at 48px Bricolage renders 846
   (-2%) where Nunito renders 810 (-6%, needing ~52px to fit). Kept at 48 so the size token is unchanged and
   only the face moves. Head still fits: 863 + 97.66 chip + 16 gap = 977 inside the 1200 container. */
.opt-skills__title {
  margin: 0;
  font-family: var(--font-bricolage);
  font-weight: 500;
  font-variation-settings: 'opsz' 14, 'wdth' 100;
  font-size: 48px;
  line-height: normal;
  color: #42604d;            /* match SHOP "WHAT YOU'LL NEED" (.opt-shop__title) exactly — same dark green */
  white-space: nowrap;
}

.opt-skills__panel {
  position: relative;
  width: 100%;
  height: 682px;
  padding: 24px 24px 0 40px;
  background: var(--color-green-card);   /* swapped with section — pale panel */
  overflow: hidden;       /* node 1485:1052 overflow-clip — grid clips to the panel */
}
/* 24, not 24.5: 2077:2903 puts Photos at x=346 against a 322-wide Options, so the gap is exactly 24
   and the half-pixel was rounding to 25 on screen. */
.opt-skills__row { display: flex; gap: 24px; align-items: flex-start; }

/* left sidebar — translucent wrapper holding the filter card + help card (mirrors .pb-plan) */
/* transparent layout column (node 1490: no translucent wrapper) — holds the filter card + help card directly */
.opt-skills__sidebar {
  flex-shrink: 0;
  width: 322px;
  display: flex;
  flex-direction: column;
  gap: 16px;   /* 2077:2851 */
}
.opt-skills__filter-card {
  background: #f0fdf1;
  border-radius: 8px;
  padding: 8px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.opt-skills__filter-h {
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: space-between;   /* "Filter" label left, icon at the row's right end (Figma 1579:704 row) */
  padding: 0 8px;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 18px;
  color: #3f6b88;
}
/* filter icon — Figma node 1579:704 "Icon-filter" doubled (Figma 20×20 read too small): 40×40 container,
   #d3f7d8, radius 9, 10px pad, 20×20 glyph. Moved here from the head row (was .opt-skills__filter-btn, 60×60 white). */
.opt-skills__filter-icon {
  flex-shrink: 0;
  width: 40px;
  height: 40px;
  padding: 10px;
  border: none;
  border-radius: 9px;
  background: #d3f7d8;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.opt-skills__filter-icon img { width: 20px; height: 20px; display: block; }
.opt-skills__filter-list {
  list-style: none;
  margin: 0;
  padding: 0 0 0 8px;
  display: flex;
  flex-direction: column;
}
/* 2075:834 — the SKILLS difficulty panel, above Filter in the resting sidebar. 322x202: a 40px SKILLS
   label row then three 30px rows on a 54px pitch (node rows at y 44 / 98 / 152). Shares the filter card's
   shell so the two read as one stack. The glyph is icon-difficulty.svg, the same mark the SCAN nav bars
   use — one asset, three sizes of bar baked in, so the levels differ by label not by icon. */
/* ══ Feed-expand (2077:2847) — a one-shot auto-play, not a hover or a click ══════════════════════
   STRUCTURE moves as one gesture (grid out, expand in, filter 5->1) on 0.6s; new CONTENT arrives
   sequenced 150ms apart after it, so the layout never looks like three reflows fighting. The SKILLS
   levels panel is untouched throughout — it is in the resting sidebar (2075:834), not part of this. */
/* GSAP OWNS opacity + transform on .opt-skills__expand, __track, __expand-row and __video (the two right-
   column rows are what travel; __detail and __skill are simply on, inside row-1), and the
   scale on the pressed photo. No CSS transition on any of them: a transition re-interpolates every
   per-frame value GSAP writes, so two easings fight over one property — which is what made this choppy.
   Only the resting state is declared here; the timeline in skillsExpand() drives it from there. */
.opt-skills__expand {
  position: absolute; inset: 0; z-index: 2;
  /* 2077:2847 gives the panel 24 top/right/bottom and 40 left — but the TOP is 0 here. In the node the
     sidebar (Options) sits INSIDE that padding alongside Photos, both at y=0 within Feed-content; in this
     build the sidebar is outside the expand, so a 24 top padding pushed the row 24 below the sidebar's top
     edge (measured 210 vs 186). 2077:2848 has both children flush, so the top comes off and the other
     three stay. box-sizing is border-box globally, so this insets rather than overflowing inset:0. */
  padding: 0 24px 24px 40px;
  display: flex; flex-direction: column; gap: 24px;
  opacity: 0; visibility: hidden;
}
/* The filter collapse is a TWEEN on the timeline now, not a display swap. display cannot be animated, so
   the four inactive rows were being hidden instantly while everything around them eased — the one motion
   in this section that was not on the timeline. GSAP tweens their height and opacity to 0 instead, which
   needs overflow:hidden here and nothing else; the duplicate --expand <li> that the swap required is gone. */
.opt-skills__filter-item { overflow: hidden; }
.opt-skills__filter-item:first-child { height: 40px; }   /* 2075:878 — the active row is the short one */

.opt-skills__expand-row {
  display: flex; gap: 24px; align-items: flex-start;
  background: #aef5b7; border-radius: 8px; padding: 48px;
}
.opt-skills__expand-item { margin: 0; width: 180.5px; flex: 0 0 auto; }
.opt-skills__expand-photo { width: 180.5px; height: 158px; border-radius: 8px; object-fit: cover; display: block; }
.opt-skills__expand-cap {
  margin-top: 8px; font-family: var(--font-body); font-weight: 400;
  font-size: 14px; line-height: 19px; color: #3f6b88;
}
.opt-skills__detail { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
.opt-skills__detail-eyebrow {
  font-family: var(--font-ui); font-weight: 300; font-size: 18px; line-height: 21px; color: #42604d;
}
.opt-skills__detail-h {
  margin-top: 4px; font-family: var(--font-bricolage); font-weight: 500;
  font-variation-settings: 'opsz' 14, 'wdth' 100;
  font-size: 44px; line-height: 59px; color: #42604d;
}
.opt-skills__detail-skills { list-style: none; margin: 18px 0 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.opt-skills__skill {
  display: flex; align-items: center; gap: 16px;
  font-family: var(--font-ui); font-weight: 300; font-size: 22px; line-height: 31px;
}
.opt-skills__skill img { width: 24px; height: 24px; display: block; flex: 0 0 auto; }
.opt-skills__skill.is-done { color: #42604d; }
.opt-skills__skill.is-active { color: #ff3567; }

/* align-items FLEX-START: 2077:2937 puts the steps card at y=16 inside the video block, i.e. on the
   block's own padding. Centring it put the top inset at 28 instead — the card is scaled to fit, so it
   is shorter than the block and the leftover was being split above and below it. */
.opt-skills__video {
  position: relative; flex: 1 1 auto; min-height: 0;
  background: #1e2e29; border-radius: 16px; overflow: hidden;   /* 2077:2935 — radius 16, not 8 */
  display: flex; align-items: flex-start; justify-content: flex-end; padding: 16px;
}
.opt-skills__video-art { position: absolute; left: 12%; top: 50%; transform: translateY(-50%); height: 78%; }
/* 2077:3055 — a 45x45 disc inset 7.5 in a 60 frame. Was 111x111 on a CSS rgba(255,255,255,0.35) disc:
   the node has since changed from a 111 frame with a 96 ellipse, and the grey disc was never in it at all.
   icon-play.svg already carries its own circle (#5D8568 @75%) and glyph (#E6F8E9) at exactly 45x45, so
   the element is just the asset — no background colour, no border-radius. */
.opt-skills__video-play {
  position: absolute; left: 34%; top: 50%; transform: translate(-50%, -50%);
  width: 45px; height: 45px; border: 0; padding: 0;
  background: url("../../Assets/icon-play.svg") center / 45px 45px no-repeat;
}
/* SCALED TO FIT, NOT CLIPPED. The available box is 254 — the 658 clip less 48 panel padding, less row-1
   (300) and its 24 gap, less the video block's 16/16 — and the card's own content is taller than that, so
   it is scaled rather than cropped: clipping would hide content the design shows.
   SIZED TO THE CONTENT, NOT THE SPEC BOX. 2077:2940 is 308 tall and carries slack; the card's NATURAL
   content height here is 274 (the two steps 05–06 plus "Complete so far"), so 254/274 = 0.927 is the largest
   scale at which all of it still fits. Step 07 was removed and the height HELD at 274 deliberately — the
   list is flex:1 1 auto, so the vacated third row stays blank and "Complete so far" keeps its footer seat.
   Both numbers move together with the step count — when the node ran six steps they were 373 and 0.681. Scoped to the CARD: the table illustration and play button are
   outside this element and keep their size. */
.opt-skills__steps {
  height: 274px;
  zoom: 0.927;
  position: relative; width: 322px; flex: 0 0 auto;
  background: #5d8568; border-radius: 8px; padding: 16px;
  display: flex; flex-direction: column; gap: 8px;
}
.opt-skills__steps-h { display: flex; align-items: center; justify-content: space-between;
  font-family: var(--font-body); font-size: 14px; color: #ebfded; }
/* SQUARE BOX, NOT THE OLD 35x27. The glyph is icon-difficulty-med-white.svg — 45x45 with ~34.6x26.3 of
   ink inside it, and no tile behind the bars (the card's own #5d8568 shows through). 35x27 was cut for the
   60x60 tiled easy icon and would squash this one's bars vertically; 27x27 keeps the source aspect and
   lands the ink at the same ~20px width the easy glyph rendered at. */
.opt-skills__steps-h img { width: 27px; height: 27px; }
.opt-skills__steps-pill, .opt-skills__steps-foot {
  background: #6f9a7b; border-radius: 999px; padding: 10px 16px;
  font-family: var(--font-body); font-size: 14px; color: #ebfded;
}
.opt-skills__steps-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; }
.opt-skills__steps-list li { display: flex; align-items: center; gap: 16px;
  font-family: var(--font-body); font-size: 14px; color: #ebfded; padding: 6px 8px; }
.opt-skills__steps-list b { font-weight: 400; opacity: 0.75; min-width: 26px; }
.opt-skills__steps-list i { display: block; font-style: normal; opacity: 0.7; font-size: 12px; }

/* sequenced arrival of the NEW content, after the structural move */
/* resting state only — the timeline animates from here */
.opt-skills__detail, .opt-skills__skill, .opt-skills__video { opacity: 0; }
/* the pressed photo must paint above its caption; the SCALE itself is GSAP's, not this rule's */
.opt-skills__item--trigger .opt-skills__photo { z-index: 2; }
@media (prefers-reduced-motion: reduce) {
  .opt-skills__expand, .opt-skills__detail, .opt-skills__skill, .opt-skills__video { transition: none; }
}

/* 2077:2851 — the levels panel is radius 16 with padding 0 8 0 16, not radius 8 / 0 16 16. */
/* Bottom padding 20, from 2075:834: the card is 202 tall and its last level row ("Expert", 2075:860)
   ends at y=182 — a 20px foot the build was missing, so Expert sat flush on the card's edge. */
.opt-skills__levels {
  background: #f4fdf6;
  border-radius: 16px;
  padding: 0 8px 20px 16px;
}
.opt-skills__levels-h {
  display: flex; align-items: center; height: 40px;
  font-family: var(--font-body); font-weight: 400; font-size: 14px;
  letter-spacing: 0.02em; color: #3f6b88;
}
/* 4px off the header: 2075:834 puts the SKILLS label row at 40 tall and the levels stack (2075:840) at
   y=44, so the two do not abut. With the 20px foot below, the card lands at 202 — the node's height. */
.opt-skills__levels-list { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 24px; }
/* 2075:850 / 2077:2868 — INTERMEDIATE is the selected level: dark-green tile, full-white glyph, and the
   label follows to #42604D against #799BAE for the other two. The glyph is inlined rather than an <img>
   so the tile and the bars recolour independently — same reason .opt-scan__nav-like-* is inlined. One
   panel serves both places: the sidebar persists through Feed-expand, so this covers 2077:2868 too. */
.opt-skills__level {
  display: flex; align-items: center; gap: 9px; height: 30px;
  font-family: var(--font-body); font-weight: 400; font-size: 14px; color: #799bae;
}
.opt-skills__level.is-selected { color: #42604d; }
/* ALL THREE ARE <img>s NOW — icon-difficulty-easy / -med-active / -hard, each baking the tile the CSS used
   to paint (med-active the SELECTED #42604d, the other two the resting #5d8568) and each rendering at
   30x30. The tile/bar/is-selected rules that used to theme the inline svg are GONE with the last of them:
   they matched nothing once Beginner became an image, and a dead rule that looks like a state machine is
   worse than no rule. The panel is static — no JS touches .is-selected — so the baked states are the only
   states. IF SELECTION EVER GOES LIVE: an <img> cannot be re-coloured from CSS, so this becomes either
   three assets per state or a return to inline svg; do not add a lone .is-selected rule and expect it to
   reach these rows. */
.opt-skills__level-icon { width: 30px; height: 30px; display: block; flex: 0 0 auto; }
/* the solid bar fills; the two taller ones are outlines — both at 75% until selected, then full white */

/* 2075:877 — rows ABUT, no gap, on an uneven pitch: the active row is 40 tall and the four beneath it
   are 45 (y = 0, 40, 85, 130, 175, then the chevron at 220 = a 240 stack). 44-for-all was close enough
   to look right and wrong everywhere: it put every row 1px light and lost the 5px the first row gives up. */
.opt-skills__filter-item {
  height: 45px;
  display: flex;
  align-items: center;
  gap: 1.5px;                /* 1959:353 sets the dot flush: 6px ellipse at x1.5 in a 9px box, label at x9 */
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 14px;
  color: #799bae;
}
.opt-skills__filter-item.is-active { font-weight: 600; color: #3f6b88; }
.opt-skills__filter-dot { flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; background: #3f6b88; }
.opt-skills__filter-more { height: 20px; display: flex; align-items: center; justify-content: center; }
.opt-skills__filter-more svg { display: block; }

.opt-skills__help {
  height: 133px;
  padding: 0 8px 0 16px;
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.75);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 8px;
}
.opt-skills__help-h {
  height: 40px;
  display: flex;
  align-items: center;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 14px;
  color: #3f6b88;
}
.opt-skills__help-reach {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 14px;
  color: #3f6b88;
  text-decoration: none;
}
.opt-skills__help-reach svg { display: block; }
.opt-skills__help-tag {
  align-self: flex-start;
  padding: 2px 8px;
  border-radius: 24px;
  background: #f0fdf1;
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 14px;
  color: #799bae;
}

/* right — auto-marquee: photo column scrolls upward, looping seamlessly (sidebar stays static) */
.opt-skills { --skills-vp-h: 658px; }   /* panel 682 − 24 top pad. Was shared with the marquee's travel so the
                                       two could not drift; the marquee is gone, so this is purely the clip
                                       height now. The column is still ~2084 tall against it — 1959:327 shows
                                       the feed clipped at two-and-a-bit rows, so overflow:hidden stays. */
.opt-skills__viewport {
  position: relative;        /* containing block for .opt-skills__expand */
  /* SHRINKS TO WHAT THE PANEL LEAVES. Was flex-shrink:0 + width:730 — the node's strip, which fits the
     1440 panel with 60 to spare but overran the 1024 one by 74.6 (the panel's inner is 1001.3 there, less
     the 322 sidebar and the 24 gap = 655.4 available). flex-basis keeps 730 wherever 730 fits, so 1440 is
     untouched; below that the strip gives back exactly what it has to instead of hanging out of the panel.
     min-width:0 because a grid child's automatic minimum would otherwise refuse to shrink past its
     content. The two columns and the photos derive from this, so one box drives the whole strip. */
  flex: 0 1 730px;
  min-width: 0;
  height: var(--skills-vp-h);   /* clips the scrolling track */
  overflow: hidden;
  border-radius: 8px;
}
/* STATIC. SKILLS no longer scrolls — the one-loop expand into Feed-expand (2077:2847) is the only motion
   in this section. Removed with the marquee: the 9s opt-skills-photos animation and its derived travel, the
   .is-inview play gate, the viewport hover-pause, the reduced-motion opt-out, and the duplicate photo set
   that existed only to seam the mobile loop. will-change dropped too — nothing transforms this element. */
.opt-skills__track {
  display: flex;
  flex-direction: column;
}

/* DERIVED TRAVEL — was a hardcoded -1450px, which overshot the track's real bottom by 172px: the track is
   1936 tall (6 rows x 316 + 5 x 8 gap) inside a 658 viewport, so the furthest it can travel and still sit
   flush with the viewport's bottom edge is 1936 - 658 = 1278. translateY's percentage resolves against the
   TRACK's own height, so `viewport-height - 100%` IS that distance, and it re-derives itself if a row is
   added, removed or resized. No constant to update, and the last photo lands flush instead of scrolling
   past. (Captions were considered for this section and dropped, so nothing else absorbs the overshoot.) */
/* 1959:327 row rhythm: photo 316 + 1 gap + caption 27 = a 344 item, on a 348 row pitch → 4px row gap.
   Column gap stays 8. The grid lays out .opt-skills__item now, not bare <img>. */
.opt-skills__grid {
  display: grid;
  /* Was repeat(2, 361px) — the node's column. 1fr each splits whatever the viewport is above, so the pair
     still sums to it exactly (2 x 361 + 8 = 730 at 1440, unchanged) and re-derives when it shrinks.
     minmax(0, 1fr) not 1fr: the default minimum is min-content, which the photos would hold open. */
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 4px 8px;
}
.opt-skills__item { margin: 0; display: flex; flex-direction: column; }
/* TEXT-ONLY caption (design updated — the avatar glyph and trailing arrow are out, and all eleven are
   consistent). With the avatar gone the text loses its 22.39px indent and sits flush to the photo's left
   edge. 27px tall = 19px line in a 4px/4px band, matching the node's label frame. #3F6B88 is the same
   value the active filter row already uses — sampled off the comp render, not picked. */
.opt-skills__caption {
  margin: 1px 0 0;
  height: 27px;
  display: flex;
  align-items: center;
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 14px;
  line-height: 19px;
  color: #3F6B88;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* NOTHING RESPONDS TO A CURSOR. The :hover zoom and cursor:pointer are gone — the scale is played by the
   sequence as a mimed press on photo-1, not triggered by a user. The press is a GSAP tween now, so the
   transition that used to carry it is GONE — it would smear every frame GSAP writes. position:relative stays so the scaling photo can take z-index and paint OVER its own
   caption, which is intended. transform-origin pins the top edge: scaling from centre grew the photo 6.3px
   upward into the viewport's overflow:hidden top edge, reading as a swell rather than a press. */
.opt-skills__photo {
  /* FILLS ITS COLUMN AND KEEPS THE NODE'S RATIO. 361 x 316 is 1440's column and stays exactly that there;
     below it the photo scales with the column instead of overflowing it, and aspect-ratio carries the
     height down with the width rather than leaving 316 pinned under a narrower image. object-fit:cover
     below means the crop tightens, it does not distort. */
  width: 100%;
  aspect-ratio: 361 / 316;
  /* 316 at 1440 (reverted from a trial 260), now via the ratio above. NOTE the consequence AT 1440: at a
     348 row pitch, row 3's top sits at 696 against the 658 clip, so photos 5 & 6 do NOT peek — rows 1-2
     show, row 2's captions are cut, row 3 is hidden. Below 1440 the photos are shorter, so more of row 3
     shows through the same clip; the clip height is deliberately NOT ramped with them here. */
  height: auto;
  border-radius: 8px;
  object-fit: cover;
  display: block;
  position: relative;
  transform-origin: top center;
}
/* hover zoom — visual only; the marquee keeps running (hover-pause was intentionally dropped at v=146).
   The grid clips inside .opt-skills__viewport (overflow:hidden), so the zoom reads cleanly at the edges. */


/* ════════════════════════════════════════
   JOIN — final CTA (Figma 1183:944 — static)
   Glass card mirrors the hero CTA; geometry per node (card 695 / pill 425).
════════════════════════════════════════ */
/* ══ BUILD — 2049:1067 ═══════════════════════════════════════════════════════════════════════════
   1440x800 section; a 1200x600 panel at x120 y90 on #AEF5B7, holding a 1098x501.4 content row inset
   24/49.3. Left column 301 wide (chip + title), cards group 689 wide at x409: Card-plan 322 and
   Card-How-to-Make-it 322, 45 apart (367 - 322). STATIC — no cycling, no state swap. */
.opt-build {
  background: #d3f7d8;
  display: grid; place-items: center;
  /* Same bar clearance as SCAN and SKILLS — the bar covers this section too, so the band is reserved and
     the panel composes the deck's card scale with --scan-scale = (100vh - band)/100vh so it still fits. */
  padding-top: var(--hiw-band);
}
.opt-build__inner {
  width: 1200px; height: 600px;
  background: #aef5b7; border-radius: 8px;
  /* 2049:1069 sits at y=65.5 in the 600 and is 469 tall — (600 − 469) / 2. It tracks the CARD height and
     nothing else: at the old 501.4 cards this was 49.3, and it moves whenever they do. */
  padding: 65.5px 24px;
  display: flex; align-items: flex-start; gap: 108px;   /* 409 - 301 */
  zoom: calc(var(--card-scale, 1) * var(--scan-scale, 1));
  /* ── FEATHER LANDING. The module is ONE unit: chip, title and both cards ride in together on this one
     element, never as separate parts.
     MOTION ONLY, no opacity. The card's own deck cross-fade already owns opacity; giving the module a
     second fade would multiply the two curves and make the inside arrive on a different ramp from the
     card carrying it. It rises; the card fades; they finish together.
     Travel is 64 in MODULE units, and the module carries the deck's zoom, so it renders ~66 at 1440x1024
     and ~70 at 1920x1080 — proportional at every height rather than a fixed nudge. Deliberately not the
     10px feather used elsewhere in the build: at this duration 10px is sub-pixel for most of the ease and
     reads as a pop, not a landing.
     Duration and easing are the DECK'S OWN tokens, so the landing and the cross-fade cannot drift apart.
     The base transition is what plays on LEAVE: 0s, delayed by the full --deck-anim, so the module snaps
     back to its start only after the card has finished fading out — nothing slides away on screen. */
  position: relative;   /* the feather is `top:` — see the SLIDE-UP LANDING block */
}
.opt-build__col { width: 301px; flex: 0 0 auto; }
.opt-build__title {
  margin: 55px 0 0;   /* 2049:1083 sits at y121.66, chip ends at 97.66 */
  font-family: var(--font-body); font-weight: 400;
  font-size: 40px; line-height: 59px; color: #1e2e29;
}
.opt-build__cards { display: flex; gap: 45px; flex: 0 0 auto; }
.opt-build__card { width: 322px; flex: 0 0 auto; display: flex; flex-direction: column; gap: 8px; }   /* 2049:1090→1096→1136 abut on 8, not 16 */

/* Card-plan — 2049:1086 "Steps" is the CARD ITSELF: a 322-wide white-25% panel at radius 16 that the
   media, the Plan block and the How-to-Make-It block sit inside. It was missing entirely, so those three
   read as loose blocks on the section ground. Padding 8 is the node's own inset (Panels y=8, Body x=8).
   NO FIXED HEIGHT. It was 469 — the node's box — and the content ends 24.6 short of it, so the card wore
   8 of padding above the video and 24.6 below "Complete so far": the leftover was slack in the node's box,
   not a bottom pad. Content height + padding:8 makes the bottom equal the top BY CONSTRUCTION, at any copy
   length, instead of a second number kept in sync with the first. The card still sets the flex line (its
   content is far taller than Card-How-to-Make-it's), so the green card stretches to the REDUCED height on
   its own — 452.4 rather than 469 — and needs no value of its own here. */
.opt-build__card--plan {
  padding: 8px;
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.25);
}
.opt-build__media { position: relative; border-radius: 8px; overflow: hidden; height: 147px; }
.opt-build__media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.opt-build__play {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 45px; height: 45px;
  background: url("../../Assets/icon-play.svg") center / 45px 45px no-repeat;
}
.opt-build__media-cap {
  position: absolute; left: 16px; bottom: 8px;
  font-family: var(--font-body); font-size: 12px; color: #ffffff;
}
.opt-build__panel { background: #f4fdf6; border-radius: 8px; padding: 16px; }
.opt-build__panel-h {
  display: flex; align-items: center; justify-content: space-between;
  font-family: var(--font-body); font-weight: 400; font-size: 20px; color: #3f6b88;
}
.opt-build__panel-h img { width: 20px; height: 20px; }
.opt-build__steps { list-style: none; margin: 16px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.opt-build__steps li {
  display: flex; align-items: center; gap: 12px;
  font-family: var(--font-body); font-size: 14px; color: #799bae;
}
.opt-build__steps b { font-weight: 400; opacity: 0.8; min-width: 18px; }
.opt-build__steps span { flex: 1 1 auto; }
.opt-build__steps img { width: 16px; height: 16px; }
.opt-build__pill {
  margin-top: 12px; display: flex; align-items: center; justify-content: space-between;
  background: #ffffff; border-radius: 999px; padding: 6px 6px 6px 16px;
  font-family: var(--font-body); font-size: 14px; color: #42604d;
}
.opt-build__pill em {
  font-style: normal; background: #aef5b7; border-radius: 999px;
  padding: 8px 18px; color: #1e2e29;
}
.opt-build__foot {
  margin-top: 12px; display: flex; align-items: center; justify-content: space-between;
  font-family: var(--font-body); font-size: 14px; color: #799bae;
}

/* Card-How-to-Make-it (2049:1167) — the dark card */
.opt-build__card--how { background: #5d8568; border-radius: 8px; padding: 16px; }   /* gap comes from .opt-build__card */
.opt-build__how-h {
  display: flex; align-items: center; justify-content: space-between;
  font-family: var(--font-body); font-size: 20px; color: #ebfded;
}
/* The three hand-built .opt-build__bars <i>s are gone — this now carries the SAME asset the SKILLS steps
   card does, icon-difficulty-med-white.svg (two bars filled, third outlined, no tile: the card's #5d8568
   shows through, same as there). 34x34 because the source is 45x45 around ~34.6x26.3 of ink, so 34 puts
   the ink at the 20px height the old <i> stack stood at — same optical size, one asset instead of two
   implementations of the same glyph. */
.opt-build__how-icon { width: 34px; height: 34px; display: block; flex: 0 0 auto; }
/* 2049:1180 (Mortise + Tenon) and 2049:1262 (Complete so far-closed) are the SAME shell: 40 tall, white at
   75% on radius 24, 14px Nunito, with an icon held at the right end. They were #6f9a7b 999px pills in pale
   #ebfded — wrong fill, wrong radius, wrong ink, and both icons missing. The two differ ONLY in their ink
   and their end padding, so the shell is shared and each states its own. */
.opt-build__how-pill, .opt-build__how-foot {
  display: flex; align-items: center; justify-content: space-between;
  height: 40px;
  background: rgba(255, 255, 255, 0.75);
  border-radius: 24px;
  font-family: var(--font-body); font-size: 14px;
}
/* 290 = 16 + 137 text + 105 + 24 icon + 8, so the row is pl16/pr8 and the pair pushes apart */
.opt-build__how-pill { padding: 4px 8px 4px 16px; color: rgba(45, 105, 13, 0.85); }
/* Icon-expand (2049:1182) is ONE asset — the white rx-8 tile and the carets are both in it, at the node's
   own 7/5 inset — so it is placed at the frame's 24, not rebuilt out of a background plus a glyph. */
.opt-build__how-pill img { flex: 0 0 auto; width: 24px; height: 24px; display: block; }
/* pl16/py2 per the node. The right padding is 3, not 16: Icon/arrow-down is a 45 box around a 24 disc, so
   its own 10.5 inset carries the rest and the disc lands ~13.5 off the edge, where the node has it. */
.opt-build__how-foot { padding: 2px 3px 2px 16px; color: #3f6b88; }
.opt-build__how-foot svg { flex: 0 0 auto; width: 45px; height: 45px; display: block; }
.opt-build__how-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.opt-build__how-steps li {
  display: flex; align-items: center; gap: 16px; padding: 8px;
  border-radius: 8px;
  font-family: var(--font-body); font-size: 14px; color: #ebfded;
}
.opt-build__how-steps li.is-active { border: 1px dashed rgba(235, 253, 237, 0.5); }
.opt-build__how-steps b { font-weight: 400; opacity: 0.75; min-width: 26px; }
.opt-build__how-steps span { flex: 1 1 auto; }
.opt-build__how-steps i { display: block; font-style: normal; opacity: 0.7; font-size: 12px; }
.opt-build__how-steps em {
  flex: 0 0 24px; height: 24px; border-radius: 50%;
  background: #1e2e29 url("../../Assets/icon-play.svg") center / 24px 24px no-repeat;
}
/* the chip's play glyph is the 45px asset; scale it into the shared 27.34 tile */
.opt-chip--make-it .opt-chip__icon img { width: 27.34px; height: 27.34px; }

.opt-join {
  /* 2081:3065 supersedes the earlier read. PRICING and JOIN are now the SAME #5D8568, separated by a 1px
     gap (PRICING 375, JOIN at y=376, height 434 -> 810 total), and the FOOTER is #42604D. The band split
     is a 1px line of page background, not a colour change. */
  background: #5d8568;
  padding: 132.5px 0;        /* node: 626 frame, 361 content centred → 132.5 top/bottom */
  display: flex;
  justify-content: center;
}
/* PRICING (1959:512) — shares the JOIN card so the pair arrives as one unit. The comp's tiers are
   357.8 wide on a 1143 row, but .opt-container is min(1200, 100vw - 240), so below ~1150 the tier text
   wraps an extra line (33px) against ~1px of slack. That is absorbed by the PRICING->JOIN gap, which is
   80px of pure whitespace in the comp and the only give in the composition — expressed as a clamp() so
   it yields continuously instead of snapping at a breakpoint. Tiers stay a 3-column row throughout:
   stacking would add ~422px and cannot fit at any height. */
.opt-pricing {
  background: #5d8568;
  /* PLAIN 100%. This was calc(100% / var(--card-scale)) on the reading that a percentage inside a zoomed
     box renders narrow — it does not. Under zoom the percentage already resolves against the containing
     block in this element's own scaled units, so 100% renders back at exactly the parent's width; dividing
     again multiplied the RENDERED width by 1/scale. --card-scale is innerHeight/900, so the error was
     invisible at the 900 design height (scale 1, no-op) and grew with every pixel away from it: 1152 in a
     1024 viewport at 800 tall (128 clipped by #join's overflow), 1620 at 1440x800 (180 clipped), 1600 at
     1280x720 — and the other way at 1440x1000, where it rendered 1296 and the "full-bleed" band under-filled
     the card by 144. The zoom below still scales the CONTENT; only the box stops being double-compensated. */
  width: 100%;
  /* No vertical padding. The merged content is title (68 + 13) + row (620) = 701 design px against
     900 - 152 footer = 748 available; 32/32 of band padding pushed that to 765 and the section's TOP
     went off the card (-8 at scale 1, -48 at 1.138) while the bottom still cleared. .opt-join__main
     grid-centres it, so the spacing above and below comes from that rather than from here. */
  padding: 0;
  position: relative;   /* containing block for the divider below */
  display: flex;
  flex-direction: column;
  align-items: center;
  /* SAME transform as .opt-join__content — the two must scale identically or they drift apart as
     --card-scale moves off 1, which is exactly what put 20px of overflow on tall viewports. Scaling both
     is what makes the comp's 375 + 372 + 152 = 899 hold against the 900 card at every height. */
  zoom: var(--card-scale);
  margin-bottom: 0;
}
.opt-pricing__title {
  /* 2053:227 — 40px on a 68 line-height (was 48). The margin goes to 0 with it: the tiers frame moved
     from y=124 to y=108, which is exactly the heading's 40 + 68, so it now sits flush beneath it. */
  margin: 0;
  font-family: var(--font-bricolage);
  font-weight: 500;
  font-variation-settings: 'opsz' 14, 'wdth' 100;
  font-size: 40px;
  line-height: 68px;
  color: #ebfded;
}
/* PRICING / JOIN — ONE merged band (2052:165). No divider: the two sections are one now, so the 1px
   #E6F8E9 border-bottom that separated them is gone. Neither hairline remains in the file — the footer's
   was removed earlier — so nothing references #E6F8E9 as a divider any more. */
/* 2088:3236 — STACKED and centred: "Pricing" 48 over "Get in at the start…" 24, both Bricolage Medium on
   a shared 68 line-height. This SUPERSEDES the one-line row the node used to carry (caps PRICING + a 32px
   "- Get in…" 10px to its right, the pair 887 wide) — the header is two centred lines now, and the copy
   loses its leading dash with the row it belonged to.
   The two 68 line boxes OVERLAP by 16 (the node's mb-[-16px]), which is what puts the pair on a 52 pitch
   inside a 120-tall block: 52 + 68. Declared as the lead's negative margin, exactly as the node has it,
   rather than as a re-picked line-height — the 68 is shared and must stay shared.
   The 13 below is UNCHANGED: the node still opens the tiers wrapper 13 under the header (226 -> 239,
   where it was 200 -> 213). The pair also grew about its own centre (132+34 == 106+60 == 166), so the
   band's grid-centring needs no counter-move. */
.opt-pricing__title {
  margin: 0 0 13px;
  display: flex; flex-direction: column; align-items: center;
  font-family: var(--font-bricolage); font-weight: 500;
  font-variation-settings: 'opsz' 14, 'wdth' 100;
  line-height: 68px; color: #d3f7d8;
}
.opt-pricing__title-lead { font-size: 48px; margin-bottom: -16px; }
.opt-pricing__title-rest { font-size: 24px; }
/* 2087:3171 + 2087:3150 — Free | Monthly+Yearly | Join, 35px apart, every card 620 tall. */
.opt-pricing__row {
  display: flex;
  gap: 35px;
  align-items: stretch;
  /* NO LEFT OFFSET. This carried padding-left: min(199px, (100% - 1042px)/2) from an older read of
     2087:3171 (which put the row at x=199). That padding sat INSIDE a box the band already centres, so it
     pushed the content right within it: the heading landed on the true centre and the cards ~85px right of
     it, which read as the heading being centred over the pricing cards alone and ignoring the JOIN panel.
     The node centres the WHOLE module, JOIN panel included: 2090:3336 runs the tiers (2090:3337 at 325.01,
     562.97 wide) and Join-for-free (2090:3351 at 922.99, 192 wide) from 325.01 to 1114.99 — centre 720,
     the same 720 the header pair (2088:3236 at 472.5 + 495/2) sits on. Content-width box + the band's
     align-items:center puts it there with nothing to maintain; justify-content states the intent. */
  justify-content: center;
  /* 75% (2088:3245). Applied as a zoom on the ROW rather than as ~20 separate values, because the node
     scales EVERYTHING below the heading uniformly: 620 -> 465, 357.82 -> 268.36, 35 -> 26.25, 256 -> 192,
     48 -> 36, 203 -> 152.25, 51 -> 38.26, and every type size with them. zoom scales layout and paint
     together, so widths, heights, radii, gaps and type all follow from one number and cannot drift apart.
     The heading is outside this element and is unchanged, as the node has it. */
  zoom: 0.75;
  height: 620px;
}
/* Same hover as .opt-join__card — scale + deepened drop shadow. That rule had NO transition, so it
   snapped; both are given one here rather than propagating the missing one. */
.opt-pricing__tier, .opt-join__card {
  transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
              box-shadow 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.opt-pricing__tier:hover {
  transform: scale(1.03);
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.22);
}
@media (prefers-reduced-motion: reduce) {
  .opt-pricing__tier:hover { transform: none; }
}
.opt-pricing__tier {
  width: 357.82px; flex: 0 0 auto;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  text-align: center;
  background: #d3f7d8; border-radius: 8px;
}
/* 2087:3231 — the Free card runs the full 620 and its three lines are ALL 40px, unlike the stacked pair. */
.opt-pricing__tier--free { height: 620px; padding: 24px 0 48px; justify-content: center; gap: 10px; }
/* 2087:3198-3200 — all three lines 32 on a 44 line-height (heights 44 / 44 / 132 for three lines). */
.opt-pricing__tier--free .opt-pricing__name  { font-weight: 700; font-size: 32px; line-height: 44px; color: #1e2e29; }
.opt-pricing__tier--free .opt-pricing__price { font-weight: 600; font-size: 32px; line-height: 44px; color: #42604d; }
.opt-pricing__tier--free .opt-pricing__desc  { font-size: 32px; line-height: 44px; color: #42604d; }
/* 2087:3185 — Monthly and Yearly split the 620 with a 24px gap (298 + 24 + 298). */
.opt-pricing__stack { display: flex; flex-direction: column; gap: 24px; flex: 0 0 auto; }
.opt-pricing__stack .opt-pricing__tier { flex: 1 1 0; height: auto; padding: 24px 20px; }
.opt-pricing__name  { font-family: var(--font-body); font-weight: 700; font-size: 24px; line-height: 33px; color: #1e2e29; }
.opt-pricing__price { font-family: var(--font-body); font-weight: 600; font-size: 24px; line-height: 33px; color: #42604d; }   /* was 32 */
.opt-pricing__desc  { margin: 10px 0 0; font-family: var(--font-body); font-weight: 400; font-size: 18px; line-height: 25px; color: #42604d; }   /* was 24; 2 lines = 50 */


/* 2084:3071 — the heading and the card are ONE 8px-gap stack now. The heading's box is 80 (2 x 40) and
   "Join for free" starts at y=88, so the 35px gap that separated them becomes 8. */
.opt-join__content {
  width: 695px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}
/* chip+headline wrapper — layout-neutral on desktop (chip/headline act as direct .opt-join__content
   children, unchanged); becomes the "Desc" row at ≤430 (JOIN-810). */
.opt-join__desc { display: contents; }
/* 2052:171 — centred, two lines. Was left-aligned under a chip; the chip is gone and the mark now sits
   inside the frosted card, so the headline centres over it. */
.opt-join__headline {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}
.opt-join__headline span {
  display: block;
  font-family: var(--font-body);
  /* 2052:171 — 40px on a 40 line-height. Both the size AND the leading dropped: at 68 the two lines
     measured 136 tall where the node's text box is 80 (2 x 40). Scoped carefully — .opt-hero__headline
     span carries an IDENTICAL 58/68 pair and must not move. */
  font-size: 40px;
  line-height: 40px;
  white-space: nowrap;
}
.opt-join__headline .l1 { font-weight: 500; color: var(--color-green-card); }
.opt-join__headline .l2 { font-weight: 400; color: var(--color-white); }

/* 2087:3150 — the Join card is now a 256-wide column IN the pricing row, and DARK: #1E2E29 with the blur,
   translucent border and shadows, where it used to be the wide #D3F7D8 frosted panel. Contents run mark ->
   input -> copy, so the copy moved below the form rather than titling it. */
.opt-join__card {
  position: relative;
  width: 256px; flex: 0 0 auto; height: 620px;
  padding: 24px;
  border-radius: 8.91px;
  border: 1px solid rgba(211, 247, 216, 0.12);
  background: #1e2e29;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow:
    0 14px 36px rgba(0, 0, 0, 0.28),
    inset 0 1px 2px rgba(255, 255, 255, 0.14),
    inset 0 -1px 6px rgba(0, 0, 0, 0.20);
  display: flex; flex-direction: column; align-items: center;
}
/* matey-logo-mark.svg is the 48x48 mark ON its #1E2E29 circle (not SVGs/logo/matey-mark.svg, the bare
   glyph). 2087:3212 drops it to y=48, and the card's own padding is 24, so 24 more here. */
.opt-join__mark { flex: 0 0 auto; width: 48px; height: 48px; margin-top: 24px; }
.opt-join__mark img { width: 100%; height: 100%; display: block; }
/* 2087:3154 — the form sits 80.91 from the card top, i.e. 48 mark + ~9 */
.opt-join__card .opt-join__form { margin-top: 9px; width: 208px; }
/* 2087:3163 — the copy, #AEF5B7 at 24px, below the input */
.opt-join__card-copy {
  margin-top: 16px; width: 208px;
  font-family: var(--font-body); font-weight: 400;
  font-size: 16px; line-height: 22px; color: #aef5b7; text-align: left;   /* was 24; 2087:3222 is 208x44 */
}
/* "Try Matey for Free" card — same hover treatment as the SHOP card: scale + deepened drop shadow.
   Keeps the inset glass finish; the outer shadow matches .opt-shop__card:hover (0 18px 40px /.22). */
.opt-join__card:hover {
  transform: scale(1.03);
  box-shadow:
    0 18px 40px rgba(0, 0, 0, 0.22),
    inset 0 1px 2px rgba(255, 255, 255, 0.35),
    inset 0 -1px 6px rgba(0, 0, 0, 0.12);
}
@media (prefers-reduced-motion: reduce) {
  .opt-join__card:hover { transform: none; }   /* no scale for opt-out users */
}
.opt-join__card-inner {
  width: 543px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  align-items: center;
}
/* 2052:174 — the Matey mark and the copy sit on ONE row inside the card. */
.opt-join__card-title {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 24px;
}
/* 2052:175 — matey-logo-mark.svg is the 48x48 mark ON its #1E2E29 circle. The build was pointing at
   SVGs/logo/matey-mark.svg, which is the bare glyph with no circle behind it (and preserveAspectRatio
   none, so it stretched to whatever box it was given). */
.opt-join__card-title p {
  /* 2052:176 is 345.5 wide beside a 48px mark; 328 was sized for the old centred, mark-less title and
     wrapped this copy to three lines. */
  width: 346px;
  font-family: var(--font-body);
  font-weight: 400;
  /* 2052:176 measures 345.5 x 66 — two lines on a 33 line-height, i.e. ~24px, not the 40 that was here
     for the old mark-less title. At 40 this copy wrapped to three lines and overflowed the card. */
  font-size: 24px;
  line-height: 33px;
  color: var(--color-green-deep);   /* node 1510:3883 — dark green text on the mint fill */
  text-align: left;
}
.opt-join__form {
  width: 543px;
  height: 71px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.opt-join__pill {
  width: 203px;      /* 2087:3156 */
  display: flex;
  align-items: center;
  padding-left: 24px;
  background: var(--color-green-accent);
  border-radius: 64px;
}
.opt-join__pill input {
  flex: 1;
  min-width: 0;
  height: 48px;
  border: none;
  background: transparent;
  outline: none;
  font-family: var(--font-body);   /* Figma: Nunito Sans (not loaded) → Nunito */
  font-weight: 400;
  font-size: 20px;
  line-height: 48px;
  color: #2d670e;                  /* existing literal (hero placeholder) */
}
.opt-join__pill input::placeholder { color: #2d670e; opacity: 1; }
.opt-join__btn {
  flex-shrink: 0;
  height: 48px;
  padding: 0 16px;
  border: none;
  border-radius: 64px;
  /* 2081:3065 gives the button #5D8568 with an #E6F8E9 label. NOT --color-green-dark, which is #2d690d
     (olive) and belongs to a different surface — that token's NAME reads right and its VALUE does not. */
  background: #5d8568;
  font-family: var(--font-body);   /* Figma: Nunito Sans Medium → Nunito */
  font-weight: 500;
  font-size: 20px;
  line-height: 48px;
  color: #e6f8e9;
  white-space: nowrap;
  cursor: pointer;
}
.opt-join__msg {
  width: 100%;
  margin-top: 12px;
  text-align: center;
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 14px;
  line-height: 18px;
  color: rgba(211, 247, 216, 0.9);
}
.opt-join__msg.error { color: rgba(255, 180, 180, 0.95); }
.opt-join__joined {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 20px;
  color: var(--color-green-deep);   /* #42604D — match line-1 title (was white/washed against the mint panel); Figma. ≤430 already set this */
  text-align: center;
}

/* 1959:541 — its own 152-tall band: 24 top + 104 content + 24. Was 52px symmetric (209 total), which
   charged 57px against the PRICING+JOIN budget that the comp does not allot it. With 152 the sums land
   exactly on the deck card: PRICING ink 375 + JOIN 372 + FOOTER 152 = 899 against a 900 design height,
   and because both the content and this band ride --card-scale the ratio holds at every viewport. */
.opt-footer {
  background: #42604d;   /* 1959:541 in 2081:3065 */
  /* The band rides --card-scale like everything else on the card. Left fixed at 152 it consumed the same
     absolute space at every height while the content scaled, so the sums only worked at scale 1: at
     1440x650 the content needed 539 against 497 of room (clipping PRICING off the top), and at 1280x1024
     it ran into the band. Scaled, the whole card is proportional and the comp's 375 + 372 + 152 = 899
     holds against the 900 design height at every viewport. Pairs with the centred transform-origins. */
  height: calc(117px * var(--card-scale));   /* 1959:541 — 24 + a 69 row + 24 */
  padding: 0;
  display: grid;
  place-items: center;
  overflow: hidden;
}
.opt-footer__inner { zoom: var(--card-scale); }

/* 1959:542 — a 1200 x 69 row. The logo (127.16) and the four columns (91 / 90 / 146 / 94) total 548.16,
   leaving 651.84 over four gaps = an even 162.96, which is exactly space-between rather than a fixed gap.
   align-items: flex-end bottom-aligns the logo with the columns: the node offsets it 7.911 down inside the
   69-tall row, i.e. its 61.09 box sits flush to the row's bottom edge. */
.opt-footer__inner {
  height: 69px;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 0;
}

/* footer brand: logo stacked above waitlist button — node 1185-1111 (logo block + button abut, 0 gap) */
.opt-footer__brand {
  flex-shrink: 0;
  align-self: flex-end;      /* 1959:543 — logo bottom sits on the row's bottom edge */
  height: 61.09px;           /* 1959:543 — the block is 61.09 in a 69 row; it was overflowing 35px above */
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0;                    /* node 1185-1111: button top = logo-block bottom; SVG's 128x62 box bakes in ~10px breathing room */
}
.opt-footer__logo {
  width: 127px;              /* node 1185-1111 logo block 127.16px; SVG aspect 128:62 → ~61px tall incl. padding */
  height: auto;
  display: block;
}

/* "Join the waitlist" pill removed (Figma node 1684:4789). Empty placeholder holds the button's former
   43px height so the wordmark↔columns gap is preserved (node keeps placeholder 1684:4824 ≈ 42.828px). */
.opt-footer__brand-spacer {
  flex-shrink: 0;
  height: 43px;
}

.opt-footer__col {
  display: flex;
  flex-direction: column;
  gap: 8px;
  font-family: var(--font-ui);
  font-weight: 400;
  font-size: 18px;
  line-height: normal;
  color: #fff;
  white-space: nowrap;
}
/* 1959:541 — 16 headings / 12 links. These existed ONLY inside the <=430 block, so desktop inherited and
   both rendered a step too large. */
.opt-footer__h { font-size: 16px; }
.opt-footer__col a { font-size: 12px; }
.opt-footer__col a {
  color: #fff;
  text-decoration: none;
  transition: opacity 0.15s;
}
.opt-footer__col a:hover { opacity: 0.7; }

/* ════════════════════════════════════════════════════════════════════════════
   DECK MODEL (?v=195) — be-ack-website panel deck ported to Matey-v2.
   Source: github.com/Shoonb/be-ack-website main @16ad8c3 (js/main.js wheel/touch
   advance()/retreatOne() + isAnimating lock; html,body{overflow:hidden} fixed 100dvh
   cards). Document never scrolls; deck() in main.js steps ONE whole card per wheel-tick
   / swipe. Cross-fade is OPACITY-ONLY (no transform → SHOP v=145 absolute-card lock
   untouched). Runtime --card-scale = innerHeight/900 uniformly scales each card's
   content to the live viewport (no crop, no reflow). MacBook-Air-only (not responsive).
   ════════════════════════════════════════════════════════════════════════════ */
:root { --card-scale: 1; --deck-anim: 1.6s; --deck-ease: cubic-bezier(0.16, 1, 0.3, 1); }
/* ≤430 deck-header rail — ONE source for the SHOP/SKILLS/JOIN header top+left so the alignment is
   structural, not three agreeing literals. Consumed in the @media(max-width:430px) block only. */
:root { --deck-head-top: 24px; --deck-head-left: 16px; }
html, body { height: 100%; overflow: hidden; }
.page-wrapper { position: relative; width: 100%; height: 100dvh; overflow: hidden; }

/* header lives INSIDE the HERO card (scales with HERO) — NOT a global overlay. */

/* every card: fixed full-viewport, opacity-only cross-fade (be-ack stack). 5 cards:
   HERO (incl. header) · SCAN · SKILLS · BUILD · JOIN (incl. footer).
   THIS LIST IS THE CONTRACT. A section does not become a card by being styled like one — it becomes a
   card by being named here, and BUILD was not: it stayed in flow at static/opacity:1, so its background
   was sized by its own content (94 + 600 x the composed zoom = 2/3 of the space below the bar), the
   remaining 1/3 showed the body ground, and with no opacity to animate it never landed — it was merely
   uncovered when the previous card faded off it. The roster is now the same five the deck's SELECTORS
   carry (main.js). .opt-shop is GONE from all three lists: that section no longer exists in the DOM (its
   panel markup lives inside SCAN as the clone source), so it was matching nothing. */
.opt-hero, #opt-scan, .opt-skills, .opt-build, #join {
  position: fixed; inset: 0; width: 100vw; height: 100dvh; min-height: 100dvh; margin: 0;
  overflow: hidden; opacity: 0; z-index: 1; pointer-events: none;
  transition: opacity var(--deck-anim) var(--deck-ease);
}
/* SCAN sits BELOW the sticky bar. Has to be on #opt-scan and after the rule above: the deck sets
   inset:0 via an ID selector, so a .opt-scan { top: } loses on specificity regardless of order — the
   card stayed at 0. Divided by --scan-scale so it renders as a literal 94 under the bar. */
#opt-scan {
}
.opt-hero.is-active, #opt-scan.is-active,
.opt-skills.is-active, .opt-build.is-active, #join.is-active {
  opacity: 1; z-index: 10; pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
  .opt-hero, #opt-scan, .opt-skills, .opt-build, #join { transition: none; }  /* direct state swap */
  /* the cards and their modules land in place rather than travelling into it */
  .opt-skills, .opt-build, #join,
  .opt-skills.is-active, .opt-build.is-active, #join.is-active { transform: none; transition: none; }
  .opt-skills__inner, .opt-build__inner, .opt-pricing { top: 0; transition: none; }
}

/* ════ SLIDE-UP LANDING (SKILLS + BUILD + PRICING/JOIN) ════════════════════════════════════════════════════════════
   These three do not cross-fade in: they RISE over the card they replace and occlude it, and the module
   inside feather-lands a beat behind the card. One motion, two triggers — SKILLS off the faux "Skills
   you'll need" click at the end of SCAN, BUILD and PRICING/JOIN off the deck landing (scroll).
   Opacity is not animated on the way in: the travel is the whole transition, so nothing is translucent
   mid-flight and nothing behind can show through. On the way OUT both properties snap back rather than
   easing — delayed by the full --deck-anim, so the card holds its painted state until whatever replaced
   it has covered it, then resets off-screen unseen. Nothing ever slides away on screen.
   Durations and easing are the DECK'S OWN tokens; the card and its module cannot drift apart. */
.opt-skills, .opt-build, #join {
  transform: translateY(100%);
  transition: transform 0s linear var(--deck-anim), opacity 0s linear var(--deck-anim);
}
.opt-skills.is-active, .opt-build.is-active, #join.is-active {
  transform: translateY(0);
  opacity: 1;
  transition: transform var(--deck-anim) var(--deck-ease);
}
/* REVERSE IS A PLAIN SCROLL. Scrolling back up cuts between cards — no glide, no feather, no cross-fade.
   deck() puts this on <html> for the single committed frame of a backward swap, so every card and module
   transition is off while the state changes and back on immediately afterwards for the next forward step. */
html.is-reverse * { transition: none !important; }

/* The OUTGOING card is held opaque while it is being covered. Without this it runs its own 1.6s opacity
   fade under the riser and the strip not yet covered visibly dims. deck() adds this for the slide's
   duration whenever the incoming card is one of the two above. */
.opt-hero.is-occluded, #opt-scan.is-occluded, .opt-skills.is-occluded,
.opt-build.is-occluded, #join.is-occluded { opacity: 1; transition: none; }
/* The module's feather. `top` on a relatively-positioned box, NOT a transform: SKILLS already rules that
   the module rise must not be an ancestor transform (the nested photo transform stops following it on iOS
   Safari), and using one idiom for both keeps the two landings identical. 64 is in MODULE units — both
   modules carry the deck's scale — so it renders proportionally at every height. The 0.25s delay is what
   makes it a LANDING rather than a ride: the card arrives, the contents settle into it. */
.opt-skills__inner, .opt-build__inner, .opt-pricing {
  position: relative;   /* .opt-pricing already sets this for its divider; stated once here for all three */
  top: 64px;
  transition: top var(--deck-anim) var(--deck-ease) 0.25s;
}
.opt-skills.is-active .opt-skills__inner,
.opt-build.is-active .opt-build__inner,
#join.is-active .opt-pricing { top: 0; }
/* The footer is a bottom-pinned band of the JOIN card, so it rides the card's slide and does NOT carry a
   feather of its own — the module that lands is the PRICING/JOIN stack. */

/* CONTENT-SCALE cards (SHOP/SKILLS): grid-center the inner stage and scale it uniformly;
   the section background fills full width → side gutters show the SECTION bg. */
.opt-shop, .opt-skills { display: grid; place-items: center; }
.opt-shop__stage {
  transform: scale(var(--card-scale)); transform-origin: center center;
}
/* SKILLS composes the deck's card scale with the BAR-CLEARANCE ratio. --scan-scale is (100vh - 94)/100vh
   — the same value SCAN's foreground uses; it is named for where it was introduced, not for one section.
   Reserving 94 with padding alone let the chip clear the bar but pushed the panel off the BOTTOM (32px
   over at 1440x810, 94 at 1440x700), because the content did not shrink to match the smaller box. */
/* ZOOM, NOT transform: scale — the same rule JOIN already learned (see .opt-join__content). transform
   scales PAINT and leaves the LAYOUT box at full size, so the card's grid was centring a 1200 x ~788 box
   that is only ever painted at ~0.6-0.9 of that. Wherever the unscaled box was taller than the padded card
   (everything below ~1440x1024), Blink stopped centring it and start-aligned it instead: the box's top went
   to the grid area's top and the shrunken paint landed around the BOX's centre — ~101px of dead gap above
   the chip and title, the panel running off the bottom, and the whole module reading low and right. zoom
   scales layout and paint together, so the grid centres the box you actually see, at every viewport. */
.opt-skills__inner {
  zoom: calc(var(--card-scale) * var(--scan-scale, 1));
}
.opt-shop__stage { position: relative; width: 1440px; height: 800px; }  /* design box → keeps .opt-shop__card top:59.5/left:50% lock */
/* 94 reserved for the sticky bar. Was padding:0 with the deck grid-centring the content, which put the
   chip and heading 30-43px UNDER the bar (measured top 51-64 against a 94 band). The card is
   place-items:center, so padding shrinks the box the content centres in and shifts it down by half the
   reservation — enough to clear, without moving the section's bottom edge. */
.opt-skills { padding: var(--hiw-band) 0 0; }   /* The bar's height comes OUT of the box before centring: the content
                                        centres in the space the header leaves, not on the whole screen, so
                                        its clearance above and below is equal within the visible band. */

/* HERO — full-bleed cover bg + full-bleed header BAND flush to the top edge (NOT an inset
   block): green spans full width, content gets 120px L/R only (Figma h80 px120 py8). Content
   below scales + centers in the region between header-bottom and card-bottom. */
.opt-hero { display: flex; flex-direction: column; }
/* SIDES COME OFF --edge-inset, NOT the node's 120. 1148:4 draws this header alone; in the build it is read
   against the sticky bar that covers the same top edge on the next card, and a flat 120 against the bar's
   ramping 24 put the wordmark 96 inside the bar's mark at 1440 and 104 at 768. Sharing the token lands the
   wordmark on the mark's edge and Join/About us on the burger's at every width. */
.opt-hero .opt-header { position: relative; z-index: 2; flex: none; width: 100%; margin: 0;
  padding: var(--sp-2) var(--edge-inset); }                  /* flush top, full-bleed */
.opt-hero .opt-header__inner { width: 100%; max-width: none; margin: 0; }   /* kill the .opt-container inset → full-bleed */
.opt-hero__main { position: relative; z-index: 1; flex: 1; display: grid; place-items: center; }
.opt-hero__inner { transform: scale(var(--card-scale)); transform-origin: center center; }

/* JOIN — content fills from the top; footer is a full-bleed band pinned to the card's BOTTOM
   edge (the section's 132.5px padding is dropped so there's no green gap below the footer). */
#join { display: flex; flex-direction: column; padding: 0; }
.opt-join__main { flex: 1; display: grid; place-items: center; }   /* content vertically + horizontally centered between the JOIN content top and the footer top */
/* ZOOM, not transform: scale. transform does not shrink the LAYOUT box, so .opt-join__main was centring
   the unscaled 683px stack inside 540px of room at 1440x650 and clipping PRICING 37px off the card top.
   zoom scales layout and paint together, so the box the grid centres is the box you see. Same value on
   .opt-pricing and the footer's inner — all three must match or they drift as --card-scale moves. */
.opt-join__content { zoom: var(--card-scale); }
.opt-footer { position: static; flex: none; width: 100%; margin: 0; }  /* full-bleed, bottom-pinned band; its symmetric 52px padding centers content vertically */

/* SCAN — one cover-cropped unit (backdrop + phone + overlays share a crop): render at
   native scale so it fills the 1440 design width; a shorter viewport clips the bottom via
   overflow:hidden (full-bleed, no gutter). NOT --card-scale'd (down-scaling would gutter). */

/* SKILLS re-author for the deck — the title slide-away was dropped here first (via animation:none), and
   both it and the photo marquee have since been deleted outright. Head + sidebar stay put; the feed is
   static and clipped by the fixed 658px .opt-skills__viewport. */
/* (the module's resting top is the SLIDE-UP LANDING block's `top: 0` on .is-active) */

/* SHOP state-2 build-steps (05–10) — TWO-PHASE reveal-scroll, one timed loop on the SKILLS
   cadence formerly shared with SKILLS's opt-skills-photos, now deleted (NOT the ambient float — discarded):
     • PHASE 1 (0–20% = 1.8s hold): track at translateY(0). The clip window fills the card
       (flex:1) and is ≥ the 6-step track, so ALL SIX steps are fully visible, none clipped.
     • PHASE 2 (20%→55% scroll up −90px, 55–65% hold, 65–90% return, 90–100% dwell, loop):
       the track scrolls bottom-to-top, so steps disappear off the TOP of the clip window, then
       returns. Clipping happens ONLY during Phase 2 (at rest, all six show).
   The −90px up-travel is the genuine overflow scrolled into (steps leave the top; the vacated
   space below them is empty, by design — there's no content beneath step 10). reduced-motion →
   none. Matches node 1461-1682. v=145 card height untouched.
   START ALIGNMENT: gated on .opt-shop.is-STATE2 (NOT .is-active). The build card is hidden during
   the 1.8s state-1 dwell; gating on .is-active would run the 0–20% hold off-screen so the scroll
   would already be moving the instant state-2 appears (begins sooner than SKILLS). Gating on
   .is-state2 starts the loop from 0% when state-2 becomes visible, so the 1.8s pre-scroll hold plays
   ON-SCREEN — the same hold SKILLS's marquee used before that marquee was removed. */
:root { --plan-travel: -90px; }   /* Phase-2 up-scroll distance (steps disappear off the top). +Npx would be a down-float instead. */
.pb-build2__steps { flex: 1 1 auto; min-height: 0; overflow: hidden; }
.pb-build2__track { display: flex; flex-direction: column; will-change: transform;
  animation: opt-shop-plan-scroll 4.5s linear infinite; animation-play-state: paused; }   /* 2× FASTER — was 9s; same proportional beats (% stops unchanged). Loop-back still gated on one full cycle. */
.opt-shop.is-state2 .pb-build2__track { animation-play-state: running; }   /* starts when state-2 becomes visible */
@keyframes opt-shop-plan-scroll {  /* proportions inherited verbatim from the deleted SKILLS marquee, at 4.5s (2×): 0.9s rest → 1.575s ease-out → 0.45s hold → 1.125s ease-in-out return → 0.45s dwell */
  0%   { transform: translateY(0); }
  20%  { transform: translateY(0);                  animation-timing-function: ease-out; }
  55%  { transform: translateY(var(--plan-travel)); animation-timing-function: linear; }
  65%  { transform: translateY(var(--plan-travel)); animation-timing-function: ease-in-out; }
  90%  { transform: translateY(0); }
  100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) { .pb-build2__track { animation: none; } }
/* "Complete so far" — pinned to the card BOTTOM as a separate layout layer, OUTSIDE the
   .pb-build2__track (node 1461-1682, file tctgh2EAd8AMJsVatskiJ5). It's a sibling of
   .pb-build2__steps (never inside the scroll); the card stretches to the 506px row height, so
   margin-top:auto pins it to the bottom — scroll and button can't fight. */
.pb-build2__complete { margin-top: auto; }

/* ════════════════════════════════════════════════════════════════════════════
   WAITLIST two-step + JOIN OVERLAY (?v=185) — v1 (Shoonb/matey) flow ported. One module
   (.js-waitlist binder in main.js) reused by the JOIN card form AND the header-Join overlay.
   Step 1 (email) / step 2 (first+last name) toggle via .opt-join__step.is-active.
   ════════════════════════════════════════════════════════════════════════════ */
.opt-join__step { display: none; width: 100%; align-items: center; justify-content: center; }
.opt-join__step.is-active { display: flex; }
/* step-2 (Figma node 1600-795 / metadata 1600:809, row 424×48): three STADIUM pills sitting
   ADJACENT (0 gap) — First 126px + Last 126px (x=126, touching) + dark-green Join 173px (x=251,
   touching), all h48, fully-rounded (radius = half-height). Group RIGHT-aligned (button at row's
   right edge) per the standing right-align decision. Exact values pulled from the node. */
.opt-join__step--name { gap: 0; justify-content: center; }                 /* footer name row CENTERED (node-1600-795 default) */
.opt-overlay .opt-join__step--name { justify-content: flex-end; }          /* overlay name row stays right-aligned (standing decision) */
.opt-join__field {
  box-sizing: border-box; flex: 0 0 auto; width: 126px; height: 48px; padding: 0 16px;
  border: 0; outline: 0; border-radius: 64px;       /* stadium — radius ≥ half of 48px */
  background: var(--color-green-accent);            /* #aef5b7 (node 1600:810/813) */
  font-family: var(--font-body); font-weight: 400; font-size: 20px; line-height: 48px;
  color: #2d670e;
}
.opt-join__field::placeholder { color: #2d670e; opacity: 1; }
.opt-join__step--name .opt-join__btn { background: #42604d; }   /* stadium radius from base .opt-join__btn (64px); node 1600:816 dark-green #42604d */

/* arrow button — icon-arrow_r.svg is a self-contained 51×48 dark-green pill + white arrow, so the
   button is transparent and sized to the icon. Footer step-2 SUBMIT + overlay step-1 NEXT use it. */
.opt-join__btn--arrow { background: none; padding: 0; width: 51px; height: 48px; border-radius: 0; flex: 0 0 auto; }
.opt-join__btn--arrow img { display: block; width: 100%; height: 100%; }
.opt-join__step--name .opt-join__btn--arrow { background: none; }   /* beat the step-2 #42604d bg (footer arrow) */

/* step dots (overlay only) — matched to node 1600-795 (1605:839): 12px circles, gap 8, centered, ~16px
   BELOW the field. The overlay form is a column so the dots stack under the field (was a flex row →
   dots sat to the right). Tone #42604d for contrast on the light panel; clickable nav kept. */
.opt-join__form { flex-direction: column; height: auto; }   /* both surfaces: stack dots below the field (was a flex row) */
/* Step dots are STATE-DRIVEN at every breakpoint: hidden by default (state-1 = email), shown only
   when the form reaches state-2 (name) via .is-step2, toggled in main.js toStep2()/toStep1(). */
.opt-join__dots { display: none; justify-content: center; gap: 8px; margin-top: 16px; }
.opt-join__form.is-step2 .opt-join__dots { display: flex; }
.opt-join__dot { width: 12px; height: 12px; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
  background: rgba(66, 96, 77, 0.25); transition: background 0.15s; }
.opt-join__dot.is-active { background: rgba(66, 96, 77, 0.9); }

/* Header-Join overlay — full-viewport layer over the active card: backdrop + centered panel + X.
   z above cards (10) and header (100). Hidden via [hidden]; .overlay-open on <html> gates the deck. */
/* matched to Figma node 1599-722 (1599:784): panel top:8 / w:695, right edge = header Join button
   (JS sets panel.right on open); #d3f7d8 + 7px backdrop-blur; NO dark scrim (hero stays visible). */
.opt-overlay { position: fixed; inset: 0; z-index: 200; background: transparent; }
.opt-overlay[hidden] { display: none; }
.opt-overlay__panel {
  position: absolute; top: 8px; width: 695px; max-width: calc(100vw - 16px);   /* right set in JS to the Join button's right edge */
  padding: 24px; border-radius: 8.91px;
  border: 1px solid rgba(211, 247, 216, 0.12);
  background: var(--color-green-card);                  /* = #d3f7d8 (node 1599:784) */
  backdrop-filter: blur(7px); -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 14px 18px rgba(0, 0, 0, 0.12),          /* node drop-shadow */
              inset 0 -1px 6px rgba(0, 0, 0, 0.12),     /* node inner shadows */
              inset 0 1px 2px rgba(255, 255, 255, 0.35);
  display: flex; flex-direction: column; gap: 16px; align-items: center;
}
.opt-overlay__close { position: absolute; top: 12px; right: 8px; width: 38px; height: 38px;   /* node 1599:771 (left 1185/top 20, 38²) → top-right of panel */
  padding: 0; border: 0; background: none; cursor: pointer; }
.opt-overlay__close img { width: 100%; height: 100%; display: block; }

/* ════════════════════════════════════════════════════════════════════════
   ≤430 MOBILE — HOME only (Figma HOME-430, node 1678:1719 — reconciled 2026-06-23; orig built to
   1654:3907, file tctgh2EAd8AMJsVatskiJ5). First responsive @media in this file. Scope: hero/header/
   headline/prompt. Touches NOTHING in SHOP / SCAN / SKILLS / JOIN (nor the four locked SHOP
   declarations). The deck's --card-scale (= innerHeight/900) is HEIGHT-based, so it does not
   fit width on phones; the hero is laid out natively here (transform reset on .opt-hero__inner).
   ════════════════════════════════════════════════════════════════════════ */
@media (max-width: 430px) {
  /* <=430 SCAN is full-bleed: no reservation, no bottom anchor. The bar's own display:none is GONE from
     here — the <=767 condensed state supersedes it, so <=430 now renders mark / selected item / burger. */
  .opt-scan { align-items: center; padding-top: 0; }
  /* HEADER — full-bleed band, px16/py8; Join only (About us dropped per HOME-430) */
  .opt-hero .opt-header { padding: 0 var(--edge-inset); height: 64px; }   /* 16 here — same token as the bar, was a matching literal */   /* hard 64px so header-bottom == the image's top:64 → no seam (was ~pad-driven). Logo stays centered via .opt-header__inner align-items:center. */
  .opt-hero .opt-btn--about { display: none; }
  .opt-logo__mark { height: 45.816px; width: auto; }                     /* HOME-430 logo height; width:auto — wordmark intrinsic aspect ALWAYS wins, Figma logo-box dims never override (committed rule, see STYLEGUIDE) */
  .opt-logo__tagline { font-size: 24px; padding: 0 0 0 var(--sp-3); }    /* 24px (desktop 32) */

  /* MAIN — cancel the deck scale and lay out natively; headline block anchored ~233px from
     the hero top (Figma top-233 = header≈62 + 171). Top-anchored, so it's dvh-robust. */
  .opt-hero__main { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; }
  .opt-hero__inner { transform: none; width: 398px; max-width: calc(100% - 16px); height: auto; margin-top: 171px; }
  .opt-hero__col { width: 100%; gap: 16px; }

  /* HEADLINE — 32/34, two lines, left-aligned within the centred 398 block (copy unchanged) */
  .opt-hero__headline { gap: 8px; }
  .opt-hero__headline span { font-size: 32px; line-height: 34px; }

  /* PROMPT pill — full width, h60, rounded-4; mark 27.5, text 16px Nunito SemiBold */
  .opt-hero__prompt { width: 100%; height: 60px; padding: 8px; border-radius: 4px; gap: 6.25px; }
  .opt-hero__prompt-mark { width: 27.5px; height: 27.5px; }
  .opt-hero__prompt-mark img { width: 21.403px; height: 21.963px; }
  .opt-hero__prompt-text { font-size: 16px; line-height: 15px; font-weight: 600; }   /* HOME-430 node 1678:1749 — leading-15 (was 20) */

  /* BG — hero-iphone12.svg (428×862, ≤430 <picture> source) rendered full + h-centered, CLIPPED BY THE
     CONTAINER (not baked). Stacks UNDER the 64px header bar (64 + 862 = 926): the bg box starts at
     top:64px and fills the height beneath, so the header's own green-light bg owns the top 64. The asset
     is the exact box aspect → object-fit:cover (base) fits, object-position center bottom → bottom-
     aligned, h-centered, sides clipped horizontally. */
  .opt-hero__bg { top: 64px; height: calc(100% - 64px); object-position: center top; }   /* TOP-anchored: header→lip region never moves; cover crops the BOTTOM on shorter dvh. Field is JS-locked to the lip (heroLipRegister). */

  /* (No PLAN·SCAN·MAKE row: removed 2026-06-22 per user; the updated authoritative comp 1678:1719
     also omits it, so live + Figma now agree — this is no longer a deviation.) */
}

/* ════════════════════════════════════════════════════════════════════════
   ≤430 MOBILE — JOIN only (Figma JOIN-810, node 1670:3). Exact spacing/gaps/type from the
   redesigned 810px-tall frame (a 393 frame applied to the 430 column: gutters/widths fluid,
   vertical rhythm verbatim — NOT scaled up). Fits one screen at BOTH 430×932 and 430×820 (svh)
   with the footer reachable and never clipped → no scroll, no main.js change, deck untouched.
   (Step-dots are state-driven globally — hidden at state-1, shown at state-2 — see the
   .opt-join__form.is-step2 rule above; they fit within the main region's slack at state-2, so
   the footer stays reachable at both states.) Touches nothing outside JOIN (no SHOP locks, no
   SCAN @container bars). */
@media (max-width: 430px) {
  /* Top-anchor the JOIN card (override the base .opt-join{justify-content:center}, line 1923). Centering +
     the fixed ~514px footer pushed the header ABOVE the viewport on short dvh (<~820: top 24→17→−11 at 743).
     flex-start pins the header at --deck-head-top like SHOP/SKILLS across the full dvh range; the confirmation
     card in .opt-join__main rides up with it. (Short-dvh footer-bottom clip is tracked separately — not this pass.) */
  #join { justify-content: flex-start; }
  /* "Title + prompt" = flex column filling the area above the footer (Figma 1670:4). pt24 pb40 px16,
     gap24. Override the deck's grid so .opt-join__content can flex-grow and the card can fill. */
  .opt-join__main { display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
    padding: var(--deck-head-top) var(--deck-head-left) 40px; }   /* top+left from the shared deck-header rail (== SHOP, SKILLS); bottom 40 is JOIN-local */
  .opt-join__content { transform: none; width: 100%; flex: 1 1 auto; flex-direction: column;
    align-items: stretch; gap: 24px; }                                 /* Figma gap-24 (Desc ↔ card) */

  /* "Desc" — Matey chip + headline side by side, gap24 (Figma 1670:7). */
  .opt-join__desc { display: flex; flex-direction: row; align-items: center; gap: 24px; width: 100%; }
  .opt-join__headline { flex: 1 1 0; min-width: 0; }
  .opt-join__headline span { font-size: 32px; line-height: 40px; white-space: normal; }   /* 32/40 #d3f7d8 */

  /* Glass "Try Matey for Free" card — grows to fill on tall dvh (Figma 1670:22 flex-1, no reduction), but is
     FLOORED so it can never shrink below its tallest state (step-2 with the progress dots). Root cause of the
     iOS short-card bug: card height was derived purely from the 100dvh→main→content flex chain with flex-shrink:1
     and no min-height — WebKit shrinks a flex item below its content (Blink stops at min-content), clipping the
     dots. min-height decouples the card from that chain (engine-agnostic floor); flex-shrink:0 belt-and-suspenders.
     step-2 intrinsic = 142px (title + name inputs + button + dots + py8); floor 150 = +8 iOS metric buffer. */
  .opt-join__card { flex: 1 0 auto; min-height: 150px; padding: 8px 16px; justify-content: center; }   /* px16 py8; justify-center → step-1 & step-2 both center in the same frame */
  .opt-join__card-inner { width: 100%; gap: 0; }                       /* no gap — the h48 title box supplies spacing */
  .opt-join__card-title { height: 48px; align-items: center; }         /* Figma Title box h48 (was text-height) */
  .opt-join__card-title p { width: auto; max-width: 100%; font-size: 24px; }   /* 24 */
  .opt-join__joined { color: var(--color-green-deep); }                /* joined subline "We'll be in touch." → title's green (was white; ≤430-only, desktop card unchanged) */
  .opt-join__msg:empty { margin-top: 0; }                              /* empty msg is a 2nd flex child of the grown card; its 12px margin skews justify-center — drop it so the visible title+subline group centers true (error msg re-adds margin when populated) */
  .opt-join__form { width: 100%; height: auto; flex: 0 0 auto; }
  .opt-join__pill { width: 100%; }
  .opt-join__pill input { font-size: 14px; }                           /* JOIN-810: 14 (was 16) */
  .opt-join__btn { font-size: 14px; }                                  /* JOIN-810: 14 (was 16) */
  /* step-dots visibility is state-driven globally (.opt-join__form.is-step2) — see the base rule;
     at ≤430 they stay hidden at state-1 and reveal at state-2 like every other breakpoint. */

  /* FOOTER — single stacked column: logo + spacer, then SCAN / PLAN / MAKE / ABOUT.
     Reconciled to current node 1684:4817 (was built to the older 1670:31 p24/gap24).
     .opt-footer__inner is also .opt-container (190px @430) → reset to full width.
     NOTE: node col→col gap is 32 (ours 24, flat DOM) — matching it exactly needs the
     paused columns-group restructure, so left at 24 for now (see report). */
  .opt-footer { padding: 24px 24px; }                                  /* Figma update node 1684:4789 — footer inner padding py 40→24 (keeps content from overrunning shorter viewports); px stays 24 */
  /* HIERARCHY-PRESERVING footer ramp (TODO #22) — three ladders keep the block hierarchy legible as the
     viewport tightens, all bottoming at an 8px block-separation FLOOR (never below 8 for the separators):
       • STRUCTURAL  logo→columns (Figma 1906:60): 24 → 16 → 8   (--foot-structural)
       • BETWEEN-GRP col→col      (Figma 1906:65): 16 → 12 → 8   (--foot-between)
       • WITHIN-GRP  header→link  (Figma 1906:66+): fixed ~4 (its minimum; does NOT drive the separator floor)
     The vars are stepped by footerGapRamp() in main.js off window.innerHeight (the true toolbar-shown height;
     @media(height) reports iOS's toolbar-HIDDEN viewport so it can't see the svh). Flat DOM shares one inner
     gap for logo→col1 AND col→col, so col→col = --foot-between and the brand-spacer adds (structural−between)
     to lift logo→col1 up to --foot-structural. Defaults = full tier if JS is absent. */
  .opt-footer__inner { width: 100%; max-width: none; flex-direction: column; align-items: center; gap: var(--foot-between, 16px); }   /* col→col (between-group) */
  .opt-footer__brand { align-items: center; gap: 0; }
  .opt-footer__brand-spacer { height: calc(var(--foot-structural, 24px) - var(--foot-between, 16px)); }   /* logo→col1 = between + this = --foot-structural */
  .opt-footer__col { align-items: center; gap: 4px; font-size: 14px; line-height: 1.3; } /* within-group (header→link / link→link) held at its ~4px minimum — does not drive the 8px separator floor */
  .opt-footer__h { font-size: 16px; font-weight: 500; }                /* column heading 16 Medium (inherits 1.3) */
}

/* ≤430 SHOP-v2 scaffold — hidden on desktop (shown in the ≤430 block). New element; desktop design unaffected. */
.opt-shop__v2 { display: none; }

/* ════════════════════════════════════════════════════════════════════════
   ≤430 MOBILE — SHOP-v2 (Figma 1763:2 "SHOP-1-v2"). All four cards live in ONE full-bleed
   "What you'll need" track — a single continuous scroll (SKILLS-style), NOT two display-swapped
   2-card state-sets. shopMobileCarousel MOVES the 4 cards (panel/sustain/pb-plan/pb-build2) into
   .opt-shop__v2-track at ≤430 and RESTORES them to their desktop parents ≥431 → desktop untouched.
   Layout (428×926): header (SHOP chip @x49 + 2-line title, y24) on the mint section bg · full-bleed
   #aef5b7 container (1763:22) whose track starts at x49 so the active 330 card centers (49px gutters;
   428−330=98) and neighbours peek · 4 dots below · buttons bar at the bottom. Track slides
   translateX = 49 − idx*346 (330 card + 16 gap), eased. Desktop (≥431) is fully untouched.
   ════════════════════════════════════════════════════════════════════════ */
@media (max-width: 430px) {
  /* section + stage → flex column; cancel the deck grid + the 1440 scaled design box */
  .opt-shop { display: flex; flex-direction: column; }
  .opt-shop__stage { position: static; width: 100%; height: auto; transform: none;
    display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }

  /* hide the desktop 2-state shells + pager + the --pb control dupe. JS MOVES the 4 cards out of the
     shells into __v2-track (empty shells stay hidden); the shop control bar is reused as the buttons bar. */
  .opt-shop__card, .opt-shop__card--pb, .opt-shop__pager { display: none; }   /* --pb control bar is NOT hidden on mobile — it's the make-state buttons bar (swapped in below) */

  /* v2 column: header (mint) → full-bleed green container (fills) → buttons bar (bottom).
     order:0/1 put the v2 block above the reused control bar (which precedes __v2 in the stage DOM). */
  .opt-shop__v2 { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; order: 0; }

  /* header (1763:5): SHOP chip + 2-line title on the mint section bg. Left rail = 16px to MATCH the SKILLS
     card's title rail at ≤430 (.opt-skills padding-left:16 → SKILLS chip@16, title@137.66). SHOP/MAKE chips are
     the same width, so chip@16 lands SHOP title@137.66 == SKILLS title. Right stays 49 (title wrap unchanged). */
  .opt-shop__v2-head { display: flex; align-items: center; gap: 24px; padding: var(--deck-head-top) 49px 8px var(--deck-head-left); }   /* top+left from the shared deck-header rail (== SKILLS, JOIN); right 49 / bottom 8 are SHOP-local */
  .opt-shop__v2-title { margin: 0; font-family: var(--font-body); font-weight: 400; font-size: 32px;
    line-height: 1.06; color: var(--color-green-deep); text-transform: uppercase; white-space: normal; }
  .opt-shop__v2-title span { display: inline; }
  .opt-shop__v2-title span + span::before { content: " "; }

  /* full-bleed "What you'll need" container (1763:22) — #aef5b7 edge-to-edge. A flex COLUMN that FILLS the
     space between header and buttons: the track-clip holds the 516 card top-anchored (flex:0 1 auto, shrinks
     on a short viewport so only the card BOTTOM clips) and the dots (flex:0 0 auto, RESERVED) center in the
     residual below the card via margin:auto. On a short viewport (iOS Safari ~743 dvh) the residual → 0, the
     clip shrinks, and the dots stay visible at the bottom — never pushed past the overflow clip. */
  .opt-shop__v2-wyn { position: relative; width: 100%; box-sizing: border-box; flex: 0 1 auto; min-height: 0;
    margin: auto 0;   /* CENTER the hugged container vertically in the free space between header and buttons → gap title→WYN == gap WYN→buttons (buttons' top margin removed so the two gaps match). auto margins collapse to 0 on a short dvh where there's no residual (top-anchors + clip absorbs), so no clipping. */
    display: flex; flex-direction: column; overflow: hidden; padding-top: 64px;   /* pt64 balances the 64px dots band (16+8+40) so the CARD re-centers within the margin:auto-centered block (was pt40 — the dots pb-24 grew the band to 64, pushing the card ~12px high; pt40 only balanced [card+dots], not the card the eye reads). Keeps the Figma dots pb-24 (node 1763:165). flex:0 1 auto → container HUGS pt64 + card + 64 dots band; flex-shrink stays 1 so a short dvh lets the clip absorb. Dots are a fixed padding band, unaffected by the hug. */
    background: var(--color-green-accent); touch-action: pan-y; }
  /* track-clip sizes to the card (flex:0 1 auto = 516 content height, but CAN shrink) and clips the wide 8-card
     track. Top-anchored (first child, under wyn's 12px pad); on a short viewport (iOS Safari ~743 dvh) the clip
     shrinks so only the card BOTTOM clips — never the top, never the dots (which are flex:0 0 auto, reserved). */
  .opt-shop__v2-clip { flex: 0 1 auto; min-height: 0; width: 100%; overflow: hidden; }

  /* the ONE track (1763:23) — flex row of the duplicated 8-card set; a rAF loop (shopMobileCarouselV2) writes
     transform per-frame for the continuous eased-velocity glide, so there is NO CSS transition/animation here
     (it would fight the per-frame writes). The base transform shows card-1 at x49 before the loop starts and
     under reduced-motion (rAF never runs). align-items:flex-start → cards keep natural heights, top-aligned. */
  .opt-shop__v2-track { display: flex; align-items: flex-start; gap: 16px;
    width: max-content; will-change: transform; transform: translate3d(var(--shop-tx, 49px), 0, 0); }

  /* the 4 moved cards → fixed 330×516 (Figma 1763:2 Card). Without an explicit height each was content-sized
     and unequal (panel 497 · sustain 489 · pb-plan 489 · pb-build2 492) — card-02 (sustain) shortest; the
     fixed height makes all four (and their marquee clones) uniform. flex:0 0 330 = no grow/shrink on width. */
  .opt-shop__v2-track > .opt-shop__panel,
  .opt-shop__v2-track > .opt-shop__sustain,
  .opt-shop__v2-track > .pb-plan,
  .opt-shop__v2-track > .pb-build2 { flex: 0 0 330px; width: 330px; height: 462px; box-sizing: border-box; }   /* 462 (Figma 1840:2332) — compressed cards so the whole stack (title+gaps+462+dots+buttons) clears a real 743 dvh with no bottom-crop */
  /* card-internal mobile sizing (carried over from the prior ≤430 build) — fill the 330 card */
  .opt-shop__panel, .opt-shop__sustain, .opt-shop__budget, .opt-shop__body, .opt-shop__upgrade { width: 330px; box-sizing: border-box; }
  .pb-plan, .pb-build2 { box-sizing: border-box; }

  /* CARD-1 device-height BLOCK fix (Figma 1826:952 outer frame): .opt-shop__panel was a backgroundless
     wrapper, so its pinned 516 box was invisible and the painted children hugged content → card read
     shorter than 516 on device. Paint the frame (translucent-white card surface + 8px inset + radius,
     per the node) so the full 516 renders as one card; children fill the 314 padded width. ≤430-only,
     scoped to the v2-track child → desktop (.opt-shop__cards home) untouched. Clones match too. */
  .opt-shop__v2-track > .opt-shop__panel { background: rgba(255, 255, 255, 0.25); padding: 8px; border-radius: 4px; }
  .opt-shop__v2-track > .opt-shop__panel > .opt-shop__budget,
  .opt-shop__v2-track > .opt-shop__panel > .opt-shop__body { width: 100%; }

  /* ── CARD-1 462 restructure (Figma 1840:2353, mobile template #shop-card1-m): s1 green #5d8568 h90 ·
     s2 (#ebfded) GROWS to fill and holds [list] + [+Add | BUY] side-by-side row + [Upgrade: title + list].
     Compressed list rows (20px tall / 24px pitch) so the whole card fits 462 with no bottom-crop. ≤430-only. */
  .opt-shop__v2-track .opt-shop__budget { background: #5d8568; height: 90px; flex: 0 0 90px; padding: 8px 0 8px 16px; }   /* s-1: drop only the RIGHT padding so the CO₂e caption runs to the right edge; keep left inset + the thumb↔content gap */
  /* Thumbnail narrowed 137→104px (≤430 only) to free the width the 14px caption needs. object-fit:cover (base)
     keeps the apartment image undistorted — it just crops narrower; height 71.4 unchanged. Derived from the fit:
     104px is the largest thumb that still lands the full 14px caption inside the right edge (~3px margin). */
  .opt-shop__v2-track .opt-shop__thumb { width: 104px; }
  /* CO₂e caption (mobile ≤430): 14px, full text on ONE line running to the s-1 right edge (thumb narrowed above
     buys the width; right padding removed on the band). Unicode ₂ scales with the font → proper subscript. ≤430 only. */
  .opt-shop__v2-track .opt-shop__co2-cap { font-size: 14px; white-space: nowrap; }
  .opt-shop__v2-track > .opt-shop__panel > .opt-shop__body { flex: 1 0 0; min-height: 0; gap: 8px; overflow: hidden; }
  .opt-shop__v2-track .opt-shop__rows .names,
  .opt-shop__v2-track .opt-shop__rows .prices { gap: 4px; }
  .opt-shop__v2-track .opt-shop__rows p { height: 20px; min-height: 20px; }
  .opt-shop__v2-track .opt-shop__actions { display: flex; gap: 8px; align-items: stretch; }
  .opt-shop__v2-track .opt-shop__actions > .opt-shop__add,
  .opt-shop__v2-track .opt-shop__actions > .opt-shop__buy { flex: 1 1 0; width: auto; margin: 0; }
  .opt-shop__v2-track > .opt-shop__panel .opt-shop__upgrade { background: transparent; padding: 0; gap: 4px; width: 100%; }
  .opt-shop__v2-track .opt-shop__upgrade-title { font-family: var(--font-body); font-weight: 400; font-size: 14px; color: #3f6b88; }
  .opt-shop__v2-track .opt-shop__urows { gap: 8px; width: 100%; }   /* was gap:70 → prices overflowed the narrower 462 card and clipped; margin-left:auto still right-aligns them */

  /* CARD-4 462 (node 1840:2555): steps 06–10 are 40px (05 stays 45) → 05-10 = 245px, which FITS the steps
     region with no overflow, so card-4 fits the 462 frame statically (button-complete ends ~413, ~49 slack)
     — no internal scroll needed. Kill the desktop marquee transform on mobile so the steps sit static. */
  .opt-shop__v2-track .pb-build2 { gap: 16px; }   /* Title+Section → Steps gap 24→16 (node y96→112) so the steps region gets the room the 245px track needs */
  .opt-shop__v2-track .pb-step { height: 40px; }
  .opt-shop__v2-track .pb-step--active { height: 45px; }   /* step 05 keeps 45 per node */
  .opt-shop__v2-track .pb-build2__track { animation: none; transform: none; will-change: auto; }   /* drop the desktop marquee layer promotion — pointless on mobile (animation off) and ×3 via the clones; only .opt-shop__v2-track stays composited */
  /* steps region HUGS the 245px track (was flex:1 1 auto → 22px empty below step 10); the 35px card slack
     now sits below Complete (matches the node). Complete margin-top -8 turns the 16px flex gap into 8px
     between step 10 and the Complete so far button. */
  .opt-shop__v2-track .pb-build2__steps { flex: 0 0 auto; }
  .opt-shop__v2-track .pb-build2__complete { margin-top: -8px; }
  /* play buttons: 45px container in the 40px step rows overflowed 2–3px → clipped at the region bottom
     (step 10). Trim the container to 40px; the visible play icon is a centered 24px svg, so this is a
     no-op visually but removes the overflow → steps region == track, 0 overflow. */
  .opt-shop__v2-track .pb-step__play { height: 40px; }

  /* CARD-2 462: impact rows 48→40 (node 1841:275x) so the 4 rows + hero + you-save clear 462. */
  .opt-shop__v2-track .opt-shop__sustain-row { height: 40px; }
  /* CARD-3 462: compress s1/s2 (node 1840:249x) — media 147→139, steps 152→136, tighter section gap. */
  .opt-shop__v2-track .pb-plan { gap: 8px; }
  .opt-shop__v2-track .pb-plan__media { height: 139px; }
  .opt-shop__v2-track .pb-plan__steps { height: 136px; }

  /* ── RADIUS reconcile (updated Figma cards 1826:952 / 1763:99 / 1827:1102 / 1763:423): clean 3-step
     ≤430 radius scale — SECTION FRAMES 4px · PILLS + step rows 24px · you-save box 8px. Node decimals
     (24.6 / 8.2 etc.) normalised to integers per the user's "no decimals". Scoped under .opt-shop__v2-track
     so DESKTOP base radii (budget 8, pb-plan/pb-build/pb-step 16) stay untouched — RULE #1. Already-correct
     values left as-is: pills buy/add/budget-pill/pill/complete/begin/next = 24 · body/upgrade/sustain/hero/
     media = 4 · sustain-save (you-save) = 8. */
  .opt-shop__v2-track > .pb-plan,
  .opt-shop__v2-track > .pb-build2 { border-radius: 4px; }        /* 516 outer: 16→4, matches cards 1 & 2 */
  .opt-shop__v2-track .opt-shop__budget,
  .opt-shop__v2-track .opt-shop__urows,
  .opt-shop__v2-track .pb-plan__steps,
  .opt-shop__v2-track .pb-build { border-radius: 4px; }           /* S1 8→4 · upgrade-list →4 · plan s2 8→4 · plan s3 16→4 */
  .opt-shop__v2-track .pb-step { border-radius: 24px; }           /* step rows 16→24 (unify with the BUY pill) */

  /* CARD-3 reconcile (node 1827:1102): the Plan steps show LABEL + check only — drop the 01/02
     numbers and align labels to the node's 8px left inset. ≤430-only (desktop pb-plan keeps its
     numbers); backgrounds already match (s1 #5d8568 / s2 #f0fdf1 / s3 white.75). */
  .opt-shop__v2-track .pb-num { display: none; }
  .opt-shop__v2-track .pb-plan__row { padding-left: 8px; }

  /* progress dots (1763:165) — 4 × 8×8, 24px pitch, centered below the track. flex:0 0 auto = RESERVED space
     in the wyn column (never pushed off / clipped); sits on the green, just below the card. */
  .opt-shop__v2-dots { flex: 0 0 auto; align-self: stretch; display: flex; gap: 16px; align-items: center; justify-content: center;
    padding: 16px 0 40px; position: relative; z-index: 2; }   /* node 1763:165 Progress-dots (pb-24) wraps the Pill (py-16) → 40px below the dots (16 Pill + 24 Progress-dots) balances the WYN pt40 above; 16px above. Folded into this one element (no separate Progress-dots wrapper in our DOM) → 64px band (16 + 8 dot + 40). Directly below the card, gap 0. flex:0 0 auto = reserved, never clips. Replaces margin:auto residual-centering — see RULE #8 note: the band no longer collapses to 0 on short dvh, so on short screens the card clips ~40px more at the bottom (the band + pt40 stay put) in exchange for a consistent 40px band on every viewport. */
  .opt-shop__v2-dots i { width: 8px; height: 8px; border-radius: 24px; background: #5d8568;
    opacity: 0.35; cursor: pointer; transition: opacity 0.25s ease; }
  .opt-shop__v2-dots i.is-active { opacity: 1; }

  /* buttons bar (1763:174) — reuse the shop control bar, centered at the bottom (order:1 → below __v2) */
  .opt-shop__controls { position: static; transform: none; align-self: center; order: 1;
    margin: 0 auto 16px; justify-content: center; }   /* top margin 0 so WYN's auto bottom-margin IS the gap to the buttons (== gap title→WYN); 16px bottom = viewport padding, matched to the header top pad */
  /* buttons bar swaps with the carousel (Figma 1765:1280): Cards 1-2 → state-1 "What you'll need..." ;
     Cards 3-4 (.pb-plan / .pb-build2) → --pb "Plan how to make it...". shopMobileCarouselV2.syncIndex()
     toggles these classes on the two control bars directly (NOT .is-v2-make on the section) so the
     per-index swap invalidates only the two small bars, never the 9-card track subtree. --pb shares the
     state-1 slot (same .opt-shop__controls class) so only display toggles; reset the desktop opacity:0 here. */
  .opt-shop__controls--pb { opacity: 1; pointer-events: auto; display: none; }
  .opt-shop__controls.is-v2-hidden { display: none; }
  .opt-shop__controls--pb.is-v2-shown { display: flex; }
  /* cards 3&4 (--pb bar): the "What you'll need" cart is demoted to an inactive .opt-shop__ctrl — dim it to
     white @ 75%. The cart SVG is authored white (fill var default) and loaded as <img>, so plain opacity
     yields true white-at-75% (no recolor needed). Scoped to the --pb bar's cart only. */
  .opt-shop__controls--pb .opt-shop__ctrl img[src*="shop-icon-cart"] { opacity: 0.75; }
  .opt-shop__controls--pb .opt-shop__ctrl-lbl { display: none; }        /* mobile: hide desktop "How to Make it..." label */
  .opt-shop__controls--pb .opt-shop__ctrl-lbl--m { display: inline; }   /* mobile: show "Plan how to make it..." */
  /* scale the buttons bar 36 → 48px (≤430 only; base/desktop stays 36). Inner icons/font scaled ×1.333 so
     the chips keep their proportions. (SHOP uses .opt-shop__ctrl — its own class, never the SCAN nav bar.) */
  .opt-shop__ctrl { width: 48px; height: 48px; border-radius: 8px; }
  .opt-shop__ctrl img { width: 26.4px; height: 22.7px; }
  .opt-shop__ctrl-main { height: 48px; padding: 0 14.7px; border-radius: 24px; gap: 3px; }
  .opt-shop__ctrl-main img { width: 34.3px; height: 29.3px; }
  .opt-shop__ctrl-main span { font-size: 16px; }
  /* play glyph scaled 18.7 → 32px: the triangle path fills only ~14/24 of its viewBox, so at 18.7 it rendered
     ~11px and read undersized next to the filled cart/heart (26.4×22.7). At 32 the triangle reads optically
     proportional to the siblings (reconciled vs Figma --pb node 1765:1643 play ~21×22, adjusted for the sparse
     triangle). ≤430-only; desktop base (16.3/14) untouched. */
  .opt-shop__ctrl-play { width: 32px; height: 32px; }
  .opt-shop__ctrl-play svg { width: 32px; height: 32px; }
  .opt-shop__bars { height: 22.7px; gap: 1.3px; }
  .opt-shop__bars i { width: 7.8px; border-radius: 2px; }
  .opt-shop__bars i:nth-child(1) { height: 5.3px; }
  .opt-shop__bars i:nth-child(2) { height: 13.6px; }
  .opt-shop__bars i:nth-child(3) { height: 20.2px; }
}

/* ════════════════════════════════════════════════════════════════════════
   ≤430 MOBILE — SKILLS (Figma 1684:4173 "INSPIRATION FEED"). Reflows the desktop
   [head | panel[ sidebar | viewport ]] into a single vivid-green column: PLAN cube +
   title, then a pale PHOTOS panel that fills the remaining dvh and clips a single-column
   photo grid. The marquee that used to run here is GONE — SKILLS is static at every width now,
   so the duplicated set that seamed the mobile loop went with it. Desktop ≥431 is untouched.
   ════════════════════════════════════════════════════════════════════════ */
@media (max-width: 430px) {
  /* cancel the deck's grid-centre + scaled design box; flow as a full-height flex column.
     (.opt-skills stays a fixed 100dvh deck card — only display/padding change here.) */
  /* + the band: the condensed bar is FIXED over this card, and the chip and INSPIRATION FEED sat under it
     (measured top 24 against a 64 band at 430). Reserved in the card's own top padding, so the inner still
     fills what is left rather than being pushed off the bottom. */
  .opt-skills { display: flex; flex-direction: column;
    padding: calc(var(--deck-head-top) + var(--hiw-band)) var(--deck-head-left) 0; }   /* vivid #aef5b7 frame; top+left from the shared deck-header rail (node 1684:4173 = pt24 px16) */
  .opt-skills__inner { transform: none; width: 100%; max-width: none; margin: 0;
    flex: 1 1 auto; min-height: 0; gap: 48px; align-items: stretch; }            /* Content (1700:705) gap-48; fills the card */

  /* title row (1684:4174): MAKE cube chip + "INSPIRATION FEED" (wraps to 2 lines) */
  .opt-skills__head { gap: 24px; align-items: center; }
  /* face pinned back to Nunito: the desktop rule moved to Bricolage for 1959:338, and <=430 has its own
     comps that were not part of that diff. Only the COPY crosses the breakpoint (one text node). */
  .opt-skills__title { font-family: var(--font-body); font-variation-settings: normal;
    font-size: 32px; line-height: 1.1; color: #42604d; white-space: normal;
    text-transform: uppercase; flex: 1 1 auto; min-width: 0; }   /* "INSPIRATION FEED" wraps to 2 lines in the row's residual width (node 1684:4182) */

  /* PHOTOS panel (1684:4183): pale #d3f7d8, fills remaining height, clips the marquee */
  .opt-skills__panel { width: 100%; height: auto; flex: 1 1 auto; min-height: 0;
    padding: 16px 16px 24px; display: flex; flex-direction: column; gap: 8px; overflow: hidden; }   /* pt16: 8px above the filter title (node 1684:4183 has Filter at y=8) + another 8px breathing room over it. Lands on the STATIC panel, above the marquee viewport/track. */
  .opt-skills__row { flex-direction: column; gap: 16px; flex: 1 1 auto; min-height: 0; align-items: stretch; }   /* gap16: 8px between filter title and photos (was 8) */

  /* filter bar (1684:4184): Filter+icon left, Need help?+arrow right — the desktop filter
     LIST + help-card chrome/tag are dropped; only the two row affordances remain. */
  .opt-skills__sidebar { flex-direction: row; justify-content: space-between; align-items: center;
    width: 100%; flex: none; gap: 8px; }
  .opt-skills__filter-card { background: transparent; padding: 0; gap: 0; flex-direction: row; }
  .opt-skills__filter-h { height: auto; padding: 0 0 0 8px; gap: 8px; justify-content: flex-start; font-size: 14px; }
  .opt-skills__filter-list { display: none; }
  .opt-skills__filter-icon { width: 24px; height: 24px; padding: 5px; border-radius: 4.5px; background: #ebfded; }
  .opt-skills__filter-icon img { width: 14px; height: 14px; }
  .opt-skills__help { height: auto; padding: 0; border: none; background: none;
    flex-direction: row; align-items: center; gap: 8px; }
  .opt-skills__help-h { height: auto; font-size: 14px; }
  .opt-skills__help-reach { font-size: 0; }   /* collapse the "Reach out" text node; the 24×24 arrow svg keeps its attrs */
  .opt-skills__help-tag { display: none; }

  /* photo column viewport — fills the remaining panel height, clips the scrolling track */
  .opt-skills__viewport { width: 100%; height: auto; flex: 1 1 auto; min-height: 0; }
  .opt-skills__grid { grid-template-columns: 1fr; gap: 8px; width: 100%; }
  .opt-skills__photo { width: 100%; height: auto; aspect-ratio: 361 / 316; }       /* tracks the desktop 361x316 */

  /* <=430 is static too — the duplicate set that seamed this loop is gone with it. */
  .opt-skills__track { gap: 8px; }
}

/* ════════════════════════════════════════════════════════════════════════
   ≤430 MOBILE — SCAN (FINAL). Figma: PiP intro 1693:179 → FULL-BLEED expand (1647:99) →
   full-bleed beat loop: 1647:99 (title-1 dark-green) · 1647:273 (tint + cyan title-2 + pointer) ·
   1647:360 (blueprint-m + measures) · AR tables 1648:468/621/633/648 → LOOP back to 1647:273.
   REVERSES the desktop aperture model: the iPhone PiP expands to fill the frame (100vw×100dvh,
   radius→0, reversed 1693:179 contraction), and every overlay beat plays FULL-SCREEN — not clipped
   to the small aperture (that's desktop-only). Portrait-authored blueprint-m.svg (blueprint + measure
   lines + labels, 393:852 ≈ frame aspect) + pointer-m.svg replace the desktop square-crop overlays.
   Driven by scanPip()'s mobile branch (.is-m2 / .is-m3; tables reuse .is-tables + .is-*-float).
   Desktop (≥431) is fully untouched. ════════════════════════════════════════════════════════════ */
@media (max-width: 430px) {
  /* INTRO chip + heading — top-left at the mobile 16/24 inset (desktop's 120px-gutter calc goes
     negative at 428). Hidden during the PiP intro; revealed on expand (step-1). */
  /* title sits on the 34px left rail across ALL beats — SCAN-0..3 (measure/perfect-fit) and SCAN-4..7
     (GET INSPIRATION) — flush with the bottom prompt field / nav-bar left edge (per Figma). */
  /* ≤430 PINS — everything the desktop three-beat rebuild changed is neutralised here, so mobile keeps
     its shipped position, gaps, type, colours and line-breaking. (Copy is shared DOM and does cross.) */
  .opt-scan__intro { gap: 24px; padding: 0; background: none; }
  .opt-scan.is-expanded .opt-scan__intro { top: calc((var(--deck-head-top) + var(--hiw-band)) / var(--scan-scale, 1)); left: 34px; padding: 0; }
  .opt-scan__heading { width: 301px; white-space: normal; font-size: 48px; transition: color 0.45s ease; }
  .opt-scan.is-expanded .opt-scan__heading { font-family: var(--font-body); font-size: 48px; color: #42604d; }
  .opt-scan__heading-measure, .opt-scan__heading-pick {
    font-family: var(--font-body); font-weight: 400; font-variation-settings: normal; }
  .opt-scan__heading-fit, .opt-scan__heading-cats { font-family: var(--font-body); }
  .opt-scan.is-expanded .opt-scan__heading-measure,
  .opt-scan.is-expanded .opt-scan__heading-inspo { font-weight: 400; }
  .opt-scan__heading-measure span:nth-child(2),
  .opt-scan__heading-measure span:nth-child(3) { display: block; }
  .opt-scan__heading-fit { margin-top: 8px; font-weight: 200; color: #00FFFF; }
  .opt-scan.is-expanded .opt-scan__heading-fit,
  .opt-scan.is-expanded .opt-scan__heading-cats { font-weight: 200; }
  .opt-scan__heading-inspo { width: 301px; color: #42604d; }
  .opt-scan__heading-cats { margin-top: 8px; }
  .opt-scan__heading-catrow { display: none !important; }   /* desktop-only; mobile keeps its own block */
  /* top: the 24px rail PLUS the band, divided by the scale this element is zoomed by, so it renders as a
     literal 24 under the bar rather than 24 from the viewport top (measured 22 with the bar over it). */
  .opt-scan__intro { top: calc((var(--deck-head-top) + var(--hiw-band)) / var(--scan-scale, 1));
    left: 34px; opacity: 0; transition: opacity 0.4s ease; }   /* mobile: align to the floating nav bar's left edge (34px rail), NOT the 8px aperture pad */
  .opt-scan.is-expanded .opt-scan__intro { opacity: 1; }

  /* PiP → FULL-BLEED expand (reverse of the 1693:179 contraction; same 0.6s curve as desktop).
     radius + shadow animate out so the phone melts into the frame on the expand. */
  .opt-scan__phone { border-radius: 28px;
    transition: width 0.6s cubic-bezier(0.22,1,0.36,1), height 0.6s cubic-bezier(0.22,1,0.36,1),
                border-radius 0.6s cubic-bezier(0.22,1,0.36,1); }
  .opt-scan.is-expanded .opt-scan__phone { width: 100vw; height: 100dvh; border-radius: 0; box-shadow: none; }

  /* ROOM video + blueprint-iphone12 + pointer-iphone12 — ONE shared crop: horizontally CENTERED, full
     height (cover), sides clipped, and NO scale(1.08) zoom so the 428×926 frame-authored assets render
     FULL (the desktop 1.08 was an aperture-seam hack, irrelevant full-bleed). All three use identical
     box + object-fit + object-position → they cover-crop in lockstep and register 1:1. (still/bg-still/
     table kept on the same crop for consistency.) */
  .opt-scan__bg, .opt-scan__bg-still,
  .opt-scan__phone-video, .opt-scan__phone-still,
  .opt-scan__table, .opt-scan__blueprint-m, .opt-scan__pointer-m {
    object-position: 50% 50%;
    transform: translate(-50%, -50%);
    /* HEIGHT REFERENCE = the VISIBLE aperture's unit (100dvh), NOT 100vh. The aperture is 100dvh; the base
       rule sized these layers in 100vh. On-device 100vh (large viewport) > 100dvh (visible, toolbar showing),
       so every layer was sized off a box ~the toolbar-height taller than what's visible — the wide cover
       layers absorbed it vertically, but the height-driven portrait blueprint (width:auto) inflated past the
       visible width and clipped at the sides. Putting all layers on 100dvh makes size + position derive from
       the SAME visible box: the apt + blueprint scale identically (both height-driven off 100dvh), the 428
       slice lands at the visible width, and they stay locked as the iOS toolbar changes the viewport. */
    height: 100dvh;
  }

  /* ≤430 room = the Apt_1389-point5 STILL, NOT the 16:9 video. The still is authored to the same
     1389.5×926 world as the blueprint/table/pointer overlays, so under the shared cover-crop they all
     register 1:1 (the 16:9 video can't). Swap to the still on expand; the scanning video drops out (it
     played during the PiP intro). The <picture> ≤430 source feeds Apt_1389-point5.svg into phone-still. */
  .opt-scan.is-expanded .opt-scan__bg,
  .opt-scan.is-expanded .opt-scan__phone-video { opacity: 0; }
  .opt-scan.is-expanded .opt-scan__bg-still,
  .opt-scan.is-expanded .opt-scan__phone-still { opacity: 1; }

  /* TINT — NOT on expand (step-1 1647:99 is bright). FEATHERS in (1.1s easeOutCubic, not the desktop
     0.45s) on the dedicated WHITE beat (is-m1w), riding in WITH the green→white recolor below — decoupled
     from the green landing (is-m1) so the old snap (tint + recolor firing the instant MEASURE arrived) is
     gone. Holds through m2/m3. is-tables keeps its own 0.5s clear (line ~729). */
  .opt-scan.is-expanded .opt-scan__tint { opacity: 0; }
  .opt-scan__tint { transition: opacity 1.1s cubic-bezier(0.33, 1, 0.68, 1); }
  .opt-scan.is-m1w .opt-scan__tint,
  .opt-scan.is-m2 .opt-scan__tint, .opt-scan.is-m3 .opt-scan__tint { opacity: 1; }

  /* TITLE — step-1 stays dark green (no tint yet). On the WHITE beat (is-m1w) title-I recolors green→white,
     EASED over 1.1s (the base .opt-scan__heading-measure transition omits `color`, so it used to snap) and
     riding in with the tint feather above. Persists through m2/m3 (is-m1w stays on the section). The cyan
     "for the / perfect fit" subtext fades in later, on the resolve beat (is-m2). */
  .opt-scan.is-expanded .opt-scan__heading { color: #42604d; }
  .opt-scan__heading-measure {
    /* ENTRANCE (opacity + transform, on is-m1) softened: longer 1.7s + a gentler ease so the green title
       feather-LANDS rather than snaps in. The green→white EXIT (color, on is-m1w) stays the 1.1s easeOutCubic
       — a separate moment (#3), left untouched. */
    transition: opacity 1.7s cubic-bezier(0.22, 1, 0.36, 1),
                transform 1.7s cubic-bezier(0.22, 1, 0.36, 1),
                color 1.1s cubic-bezier(0.33, 1, 0.68, 1);
  }
  .opt-scan.is-m1w .opt-scan__heading-measure { color: rgba(255,255,255,0.75); }
  .opt-scan.is-m2 .opt-scan__heading-fit { opacity: 1; transform: none; }
  /* MEASURE YOUR SPACE feather — ON THE EXPAND, the same trigger every other viewport uses. This tier used
     to hold it for the dedicated is-m1 beat (scanPip adds it ~0.8s after expand, once the aperture's eased
     expansion has SETTLED) so the feather would not run against the simultaneous aperture resize. That was
     the reason for the delay and it is deliberately given up here: the reveal is now one behaviour across
     every width. CONSEQUENCE, on purpose: on mobile the title rises DURING the aperture's 0.6s expansion
     rather than after it, and it no longer re-feathers on each is-m1 toggle — it reveals once per expand.
     The chip below moves with it, keeping the "as one unit" pairing the two have always had. Still clears
     fast on the tables handoff so it cannot overlap GET INSPIRATION. */
  .opt-scan.is-expanded .opt-scan__heading-measure { opacity: 1; transform: translateY(0); }
  .opt-scan.is-tables .opt-scan__heading-measure { opacity: 0; transition: opacity 0.5s ease; }

  /* SCAN chip — feather-lands AS ONE UNIT with title-I "MEASURE YOUR SPACE": same is-m1 trigger frame,
     same 1.1s easeOutCubic opacity+translateY, so the chip travels WITH the title (it used to just appear at
     opacity 1 on expand while the title rose alone). Hidden until is-m1; gone from step-3 on. */
  .opt-scan__intro .opt-chip {
    opacity: 0; transform: translateY(10px);
    /* matched to title-I's softened entrance (1.7s, gentler ease) so chip + title settle as one soft unit */
    transition: opacity 1.7s cubic-bezier(0.22, 1, 0.36, 1), transform 1.7s cubic-bezier(0.22, 1, 0.36, 1);
  }
  /* WITH the title, on the expand — the pairing is the point: these two feather-land as one unit, so the
     title's move off is-m1 has to take the chip with it or the chip trails it by ~0.8s. */
  .opt-scan.is-expanded .opt-scan__intro .opt-chip { opacity: 1; transform: translateY(0); }
  .opt-scan.is-m3 .opt-scan__intro .opt-chip,
  .opt-scan.is-tables .opt-scan__intro .opt-chip { opacity: 0; }

  /* desktop square-crop blueprint/measure/pointer OFF on mobile — replaced by the -m assets. */
  .opt-scan__blueprint, .opt-scan__measure, .opt-scan__measure-label, .opt-scan__pointer { display: none !important; }

  /* MOBILE blueprint (blueprint-1389-point5.svg, Figma 1703:3) — now the WIDE full-room art (viewBox
     0 0 1390 926), re-exported to match the apt's 1389.5×926 world (was a 428×926 slice). Because it's wide,
     it's declared IDENTICALLY to the apt still — width:100vw + height:100dvh + object-fit:cover (height inherited
     from the mobile shared-crop group; object-fit:cover inherited from the base). Both are now WIDTH-spanning,
     HEIGHT-driven cover layers off the SAME 100dvh: they scale together, overflow the 428 viewport equally
     (clipped by the phone), and the couch/window register 1:1 at every toolbar state. (The earlier 428×926
     slice was height-driven via width:auto and rendered ~361px at the 781 visible height → under-reached the
     sides; the wide re-export fixes that at the asset level.) FEATHER-IN on is-m3 (1.7s 0.22,1,0.36,1 + 10px
     translateY settle); crisp 0.5s opacity clear at tables. */
  .opt-scan__blueprint-m { display: block; opacity: 0; z-index: 3;
    width: 100vw;   /* declared identically to the apt still — width-spanning cover (asset is wide now) */
    transform: translate(-50%, calc(-50% + 10px));
    transition: opacity 1.7s cubic-bezier(0.22, 1, 0.36, 1),
                transform 1.7s cubic-bezier(0.22, 1, 0.36, 1); }
  .opt-scan.is-m3 .opt-scan__blueprint-m { opacity: 1; transform: translate(-50%, -50%); }
  .opt-scan.is-tables .opt-scan__blueprint-m { opacity: 0; transition: opacity 0.5s ease; }

  /* MOBILE "here" pointer — pointer-1389-point-5.svg, a full-frame overlay on the SAME shared crop as the
     room/blueprint (geometry above). The marker is baked low (bubble centre ≈0.78 of frame) — ~6.8% below
     the cube-table top surface (≈0.71), so it read a bit low; nudge the layer UP 6.8dvh toward the AR-table
     area, then back DOWN 28px so the bubble clears the couch base in the background image. Since this layer holds
     only the marker, the shift doesn't affect registration of anything else. Reveal + fade: in at step-2,
     persists through step-3, clears at tables. */
  .opt-scan__pointer-m { display: block; opacity: 0; z-index: 4; transition: opacity 1.1s cubic-bezier(0.33,1,0.68,1);
    transform: translate(-50%, calc(-50% - 6.8dvh + 28px)); }
  .opt-scan.is-m2 .opt-scan__pointer-m, .opt-scan.is-m3 .opt-scan__pointer-m { opacity: 1; }
  /* CRISP pointer EXIT on the tables handoff (0.5s, matching blueprint-m/tint) so the pointer is FULLY gone
     before table-1 begins — the JS delays startTables 750ms after is-tables, leaving a clean gap (no crossfade). */
  .opt-scan.is-tables .opt-scan__pointer-m { opacity: 0; transition: opacity 0.5s ease; }

  /* AR TABLES — feather-LAND in with the green-title language (1.7s, 0.22,1,0.36,1, opacity + a 10px translateY
     settle), replacing the desktop 1.2s ease-in-out fade. Base centring is translate(-50%,-50%) (shared-crop
     group above); rest sits 10px low, is-active settles to centre. Fade-OUT keeps the 0.9s advance rhythm
     (transform rides it out too, a soft downward exit). Mobile-only — desktop tables untouched. */
  .opt-scan__table {
    transform: translate(-50%, calc(-50% + 10px));
    transition: opacity 0.9s cubic-bezier(0.4, 0, 0.2, 1), transform 0.9s cubic-bezier(0.4, 0, 0.2, 1);
  }
  .opt-scan__table.is-active {
    opacity: 1; transform: translate(-50%, -50%);
    transition: opacity 1.7s cubic-bezier(0.22, 1, 0.36, 1), transform 1.7s cubic-bezier(0.22, 1, 0.36, 1);
  }

  /* STEP-3 — title + chip clear for the blueprint (.opt-scan__intro fades out). */
  .opt-scan.is-m3 .opt-scan__intro { opacity: 0; }
  /* TABLES — "GET INSPIRATION" re-shows (is-tables swaps the heading variant; chip stays hidden). */
  .opt-scan.is-tables .opt-scan__intro { opacity: 1; }

  /* NAV FLOATS — coded from nav-cube-m.svg (398×86 ref: bar 360×48 rx4 inset 18.7 in a 16px-margin
     frame → its left edge lands on the 34px title rail). 64px above the bottom. Overrides the desktop
     @container aperture tiers (which would pick the 289×38 tier at the full-bleed 428 aperture); the bar
     is container-type:size, so the content px are re-fixed below (cqh against 48px would be too small). */
  .opt-scan__nav-float--cube, .opt-scan__nav-float--round,
  .opt-scan__nav-float--chunky, .opt-scan__nav-float--japanese, .opt-scan__nav-float--category {
    width: 360px; height: 48px; border-radius: 4px; left: 34px; bottom: 64px;
    transform: translateY(10px);   /* rest 10px low → feather-start for the inaugural bar (settles to 0 below) */
  }
  /* shown bar settles to rest (translateY 0). Tables 2–4 hard-swap (base has no transition → they SNAP to rest);
     only the inaugural cube bar feathers, armed by is-first-table below. */
  .opt-scan.is-tables.is-cube-float .opt-scan__nav-float--cube,
  .opt-scan.is-tables.is-round-float .opt-scan__nav-float--round,
  .opt-scan.is-tables.is-chunky-float .opt-scan__nav-float--chunky,
  .opt-scan.is-tables.is-japanese-float .opt-scan__nav-float--japanese { transform: translateY(0); }
  /* INAUGURAL bar feather-lands WITH table-1 as one unit — green-title language (1.7s soft, opacity + translateY).
     Overrides the global is-first-table 1.2s ease-in-out. Dropped by JS at 1800ms (after the 1.7s settles). */
  .opt-scan.is-first-table .opt-scan__nav-float {
    transition: opacity 1.7s cubic-bezier(0.22, 1, 0.36, 1), transform 1.7s cubic-bezier(0.22, 1, 0.36, 1);
  }
  /* internal layout measured from nav-cube-m.svg: 30px icon chips · 5px chip gaps · 8px inset
     (bar-left→first chip) · 6px icons→title · arrow 12px off the right edge (space-between pins it). */
  .opt-scan__nav-row { padding: 0 12px 0 8px; gap: 8px; }
  .opt-scan__nav-left { gap: 6px; }
  .opt-scan__nav-right { gap: 8px; }
  .opt-scan__nav-icons { gap: 5px; }
  .opt-scan__nav-icon { height: 30px; }
  .opt-scan__nav-title { font-size: 13px; letter-spacing: 0.04em; }
  /* compressed mobile nav (Figma SCAN-4-1389-point-5): the bar is too narrow at mobile width — drop the
     NEXT label + node thumbnail; the right cluster becomes the arrow alone (icons + title stay left). */
  .opt-scan__nav-check { display: none; }
  .opt-scan__nav-arrow { height: 22px; }
}
