/* =============================================================================
   Bigscreen store — Beyond 2 product configurator (bs-product-configurator).
   Register: full-width configurator split (the one page that runs edge-to-edge):
   sticky grey render stage left, white config panel right (Polestar register,
   light store palette). Type sits on the deck scale. Tokens from bs-chrome.css.
   ============================================================================= */

.bsp {
  --bsp-ink: #0a0a0c;
  --bsp-muted: #70777A;      /* readable grey on white (Reading Grey is too light here) */
  --bsp-line: rgba(10, 10, 12, 0.3);
  background: #fff;
  color: var(--bsp-ink);
  /* explicit: everything in the configurator is Neue Haas unless marked mono —
     without this, unmarked text inherits Dawn's Assistant */
  font-family: var(--font-display);
  font-weight: 400;
}
.bsp-split {
  display: grid;
  /* Polestar verdeling: fixed-width config rail, stage takes the rest. The
     block spans the window minus a slim outer gutter (Polestar's ~30px) —
     wide, but never touching the raw screen edge. */
  grid-template-columns: minmax(0, 1fr) clamp(460px, 46%, 690px);
  /* Near-full-width, Polestar register. Containing it to the site width was
     tried and reverted: the rail's fixed clamp meant the frame's loss came
     entirely out of the stage (~50/50), and the edge-alignment with the story
     bands buys nothing because the sticky stage scrolls away before those
     edges are ever on screen together. */
  margin-inline: clamp(16px, 2vw, 40px);
}
@media (max-width: 991.98px) {
  .bsp-split {
    margin-inline: 0;
    grid-template-columns: 1fr;
  }
  /* The stage row is pinned to the plate's OPENING height, so the plate can
     shrink as the panel scrolls into it (see the mobile stage block) without
     taking that height out of the document. Without this the collapse would
     pull ~200px of flow out from under the reader mid-scroll and the page would
     jump. Beyond only — nothing else folds, so nothing else needs its row held
     open, and an accessory's plate is free to size to itself. */
  .bsp--beyond .bsp-split { grid-template-rows: max(300px, 44vh) auto; }
}

/* ---- Render stage (left): the grey box, product floats in whitespace ---- */
.bsp-stage {
  position: sticky;
  /* locked under the chrome from the start (Polestar behavior): sticky offset
     equals the page's chrome offset, so the stage never travels — only the
     config column scrolls. Free-standing plate: it stops short of the viewport
     bottom and carries the signature bevel cut on the lower-right corner. */
  top: calc(var(--ticker-h) + 112px);
  height: calc(100vh - var(--ticker-h) - 112px - 24px);
  height: calc(100dvh - var(--ticker-h) - 112px - 24px);
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--off-white);
  border-radius: 3px 3px 32px 3px;
  corner-shape: round round bevel round;
  overflow: hidden;
}
/* ---- Mobile: the panel scrolls INTO the plate ------------------------------
   The desktop register is a two-column split where the stage holds one whole
   side of the screen. On a phone there is only one column, so a 44vh plate and
   the sticky order footer sandwich the configuration into ~175px of reading
   window — less than one option card on a small phone, against ~5000px of
   options to scroll. Shrinking the plate outright is the wrong trade, though:
   the render IS the configurator's feedback loop, and losing it means picking a
   shell colour blind.

   So the plate gives its height up gradually, and the config panel is what takes
   it. Every pixel the panel scrolls up removes a pixel from the plate, 1:1,
   until the plate reaches its floor and simply stays there for the rest of the
   flow. The panel appears to push the product smaller as it rises — one motion,
   directly under the thumb, with no threshold anywhere in it.

   This replaced a class toggle on an IntersectionObserver, which was the wrong
   model however the trip line was tuned: a single boolean means the plate is
   only ever two sizes, so the change can only ever arrive as a jump — at a
   position the visitor has no way to predict, and running on its own clock
   rather than theirs. Continuous is what makes it feel attached to the scroll
   instead of triggered by it.

   --bsp-fold is the px of height already taken, set per animation frame by the
   section script; --bsp-p is the same thing as 0-1 for the fades. With JS off
   both default to 0 and the plate simply stays at its opening height, which is
   the state this page shipped with.
   -------------------------------------------------------------------------- */
@media (max-width: 991.98px) {
  .bsp-stage {
    top: calc(var(--pill-top) + var(--pill-h) + var(--ticker-h) + 8px);
    z-index: 5;
    /* The plate is inset to the page gutter rather than run edge to edge, so its
       left edge lands on the same 16px as .bsp-col-inner's copy below it and the
       bevel reads as a corner of an object instead of being cut off by the
       screen. Set on the stage alone, NOT on .bsp-split: the split also holds
       the config column, which already carries its own 16px padding, and
       insetting the grid would push all the copy to 32px and shift the whole
       panel off the page's axis. Desktop's own slim outer gutter is on
       .bsp-split, which is why this only appears here. */
    margin-inline: 16px;
    /* --bsp-h is the plate's height for THIS frame: the distance from the chrome
       down to wherever the config panel's top edge currently is, floored. The
       script measures that gap rather than counting scrolled pixels, which is
       what guarantees the panel can never slide underneath the plate — the
       plate's bottom edge IS the panel's top edge until the floor is reached.
       The fallback is the opening height, so with JS off nothing moves.
       NO transition: the value is rewritten every frame, and a transition would
       make each frame chase the last one and lag behind the thumb. */
    height: var(--bsp-h, max(300px, 44vh));
    /* the grid row above holds the flow height; the plate is free to resize */
    align-self: start;
    /* Paints the band between the chrome and the plate. The floating pill and
       the ticker leave a slot open above the plate — and a wider one whenever
       the ticker retracts — so option rows and prices scrolled up through it in
       half-clipped slivers, right against the plate's hard top edge. Elsewhere
       on the site that band is the "chrome floating over content" look; here it
       reads as tearing, because there is a fixed surface directly under it.
       A shadow rather than a pseudo-element: the plate is overflow:hidden for
       the render, which would clip any child, but an element's own box-shadow
       is painted outside that clip. Offset up by its own spread so the rect
       lands flush on the plate's top edge with no seam. */
    box-shadow: 0 -200px 0 200px #fff;
  }
  /* ---- Everything that is not Beyond 2 -------------------------------------
     No stick, no fold: the plate is one photograph at the top of a short page,
     so it scrolls away like any other image and the page behaves normally under
     the thumb. The white shield goes with it — that band exists to stop the
     config panel tearing past a pinned plate, and nothing is pinned here.
     Its photo also gets real margin. These are photographs, not the 4:3 renders
     with transparent air baked in, so a frame sized to the plate puts the
     product hard against all four edges — the accessory pages looked cramped
     where the configurator looked full-bleed, from identical CSS. */
  .bsp:not(.bsp--beyond) .bsp-stage {
    /* relative, NOT static: the plate is the containing block for the gallery
       arrows, the counter and the rating, all absolutely positioned against it.
       Taking it out of the positioned flow entirely dropped them onto the next
       positioned ancestor — the arrows landed below the plate and the counter
       ~500px further down the page. Relative un-sticks it and keeps the anchor. */
    position: relative;
    top: auto;
    box-shadow: none;
  }
  .bsp:not(.bsp--beyond) .bsp-stage-inner {
    width: calc(100% - 48px);
    height: calc(100% - 48px);
  }
  /* "your configuration" belongs to a build, not to a strap */
  .bsp:not(.bsp--beyond) .bsp-stage-tag { display: none; }
  /* The rating leaves over the first ~45% of the travel, faded by the same value
     that drives the height so it goes as the plate closes rather than on a timer
     of its own. It had its moment on arrival and is stated again in full at the
     reviews band, and a 120px strip has room for the product and one readout —
     which the add-on corner is already using (see the add-on block below, which
     deliberately does NOT fade). */
  .bsp-rating { opacity: clamp(0, calc(1 - var(--bsp-p, 0) * 2.2), 1); }
  /* nothing invisible stays tappable or readable */
  .bsp--folded .bsp-rating { pointer-events: none; visibility: hidden; }
}
/* VRChat Edition backdrop: tinted art washes over the whole plate.
   bs-vrchat-bg.webp is a placeholder — swap for sourced art; the badge
   top-right becomes the VRChat logo image when supplied. */
/* VRChat Edition backdrop ONLY: a contained sticker panel centred on the
   plate at full saturation (no wash), the product render sitting over it.
   Hidden on every other configuration. */
.bsp-stage-bg {
  position: absolute;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: min(66%, 700px);
  aspect-ratio: 1 / 1;
  background-image: url('background_vrchat.webp');
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  opacity: 0;
  transition: opacity 700ms cubic-bezier(.45, .05, .35, .95);
  pointer-events: none;
}
.bsp-stage--vrchat .bsp-stage-bg { opacity: 1; }
.bsp-stage-vrc {
  position: absolute; top: 24px; right: 28px;
  opacity: 0;
  transition: opacity 700ms cubic-bezier(.45, .05, .35, .95);
  pointer-events: none;
}
/* the white logo flips dark to sit on the light plate */
.bsp-stage-vrc img { display: block; height: 32px; width: auto; filter: brightness(0); opacity: .72; }
.bsp-stage--vrchat .bsp-stage-vrc { opacity: 1; }
/* The VRChat art is a full-plate composition, so it goes with the fold like the
   rest of the plate chrome. Scoped to .bsp-stage--vrchat and placed after the
   rules above because those are what it has to outrank — a bare .bsp-stage-bg
   selector in the mobile block loses to them and the art stays at full strength
   all the way down into the strip. */
@media (max-width: 991.98px) {
  .bsp-stage--vrchat .bsp-stage-bg,
  .bsp-stage--vrchat .bsp-stage-vrc {
    opacity: clamp(0, calc(1 - var(--bsp-p, 0) * 2.2), 1);
  }
}

.bsp-stage-inner {
  position: relative;
  z-index: 1;
  width: min(82%, 960px);
  aspect-ratio: 4 / 3;
}
/* Mobile: sized as a PERCENTAGE of the plate, so the product scales with the
   fold for free and stays centred at every size — the plate and the thing on it
   are one object being compressed, not two elements animating separately.

   Near-full-bleed, rather than the desktop's 82%-wide 4:3 box above. A phone
   plate is only 375px across, and those proportions left the headset floating in
   the middle with grey to spare on all four sides, wasting the scarcest thing on
   the page; at the floor it wasted the strip outright, putting a 74px render in
   a 120px band. The aspect-ratio comes off with it — the box simply takes the
   plate, and object-fit:contain on the render keeps the hardware's own
   proportions inside it. The product is then as large as the plate allows at
   every point of the shrink: ~360px wide at hero, ~108px tall at the floor.

   Has to sit AFTER the rule above, not up in the mobile stage block: both are
   one class deep, so this is decided purely on source order, and the copy that
   lived up there lost to the 82% and never applied at all. */
@media (max-width: 991.98px) {
  .bsp-stage-inner {
    /* A 16px gutter, held at every size — the same inset .bsp-col-inner gives
       the copy below, so the render sits on the page's own margin rather than
       running off the plate. This was briefly 122% of the plate, bleeding the
       frame past both edges to win size; the size is now taken vertically
       instead (see the height below), which costs the folded strip nothing
       because the render is limited by HEIGHT there, not width. The hero plate
       is the one that pays: the render is width-bound at that size, so it is
       ~343px rather than the ~457px full-bleed bought. */
    width: calc(100% - 32px);
    /* A 44px band at the bottom of the plate, reserved for the strip's label and
       the add-on line — but only once there is something in it. The reserve is
       scaled by the fold, so at hero height it is zero and the render has the
       whole plate, and at the floor it is the full 44px the two lines need.
       A fixed reserve would have left the hero render sitting 40px high in an
       empty band; a percentage of the plate cannot serve both ends, since
       whatever clears type inside a 132px strip throws away most of a 371px one.
       Centred, so the frame's overspill falls evenly top and bottom. */
    /* The full plate, with nothing reserved. In the folded strip the render is
       limited by HEIGHT, so any band held back below it comes straight off the
       product — 32px of reserve was costing the strip a fifth of its render.
       The "your configuration" label overlays the frame's bottom-left instead,
       which is empty in every render: these are 4:3 frames with the hardware
       floating in transparent margin, and the label paints over that margin
       rather than over anything. It is also a later sibling, so it sits on top
       without needing a z-index.

       Past 100% in the folded strip, so the frame is TALLER than the plate and
       the render stops being limited by the strip's height. The overspill is the
       frame's empty top and bottom margin, which the plate clips, so the
       hardware grows and none of it is lost. This is where the folded strip gets
       its size now that the width is gutter-bound — and it is the right axis for
       it, since height is what actually constrains the render down there.
       Scaled by the fold, so the hero plate is untouched. Centred
       rather than top-anchored (dropping the earlier align-self:flex-start), or
       the whole overflow would fall on one side and slide the product down. */
    height: calc(100% + 40px * var(--bsp-p, 0));
    aspect-ratio: auto;
  }
}
/* direct children only: the add-on chip now lives inside this box too, and a
   descendant selector was capturing its thumbnail — absolutely positioning it
   over the whole stage at opacity 0, i.e. invisible */
.bsp-stage-inner > img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  opacity: 0;
  transform: scale(1.015);
  /* symmetric curve for the fade: the signature expo ease front-loads so hard
     a long fade still reads as a snap; this one spends its time mid-fade */
  transition: opacity 800ms cubic-bezier(.45, .05, .35, .95), transform 1300ms var(--ease-signature);
  will-change: opacity, transform;
}
.bsp-stage-inner > img.is-active { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  .bsp-stage-inner > img { transition: none; }
}

/* ---- Photographs take the whole plate --------------------------------------
   The box above is sized for a cut-out render: 82% of the plate, 4:3, hardware
   floating in transparent air. A shot taken on set has a background of its own,
   so the same treatment renders it as a small letterboxed rectangle marooned in
   grey — the picture stops at an edge that isn't its own. These fill the plate
   and crop to it, like every other photograph on the site (.bss-media).

   Sized in container units off the plate rather than by widening the box,
   because the treatment is decided per IMAGE and the two cross-fade against each
   other: while a photo fades into a render both are in the same box, and only a
   rule that ignores the box can give them different geometry. `container-type:
   size` is safe here — the plate's height is explicit at both breakpoints
   (the desktop calc and mobile's --bsp-h), so nothing about it is content-sized.

   Centred with negative margins, NOT with a translate, and not with `margin:
   auto` either. Translate shares the property the cross-fade animates (scale
   1.015 -> none over 1300ms), so gaining or losing this class animated the
   centring offset too and the picture slid in diagonally from the corner. Auto
   margins look like the fix but only centre vertically here: this box is WIDER
   than the one it is positioned in, and the over-constrained rule zeroes
   margin-left rather than letting it go negative, which parks the photo 66px to
   the right of the plate.

   Careful with `inset: 0` above: it sets all four offsets, so right/bottom have
   to be released before left/top can be re-anchored to the centre. */
.bsp-stage { container-type: size; }
.bsp-stage-inner > img.is-bleed {
  right: auto; bottom: auto;
  left: 50%; top: 50%;
  width: 100cqw; height: 100cqh;
  margin: -50cqh 0 0 -50cqw;
  object-fit: cover;
}

/* The plate's chrome is dark-on-#F4F3F3 and vanishes the moment a photograph is
   under it. So it inverts: the same marks, in Instrument White, straight on the
   picture. No plate behind them — a white card floating over a photograph is the
   generic overlay-panel look the brief rules out, and it fences off a corner of
   the shot we just went to the trouble of making full-bleed.

   The shadow is the whole reason it can go unfenced: one soft dark pass under
   the type holds it against a light patch without dimming the picture. It is
   legibility, not elevation, so it doesn't break the Flat Stage Rule.

   The stars turn white here too rather than staying violet. #4D1EF7 is darker
   than the grey seamless behind it — near-identical luminance, so the marks
   simply disappear — and the violet has a job on this page already (the order
   button). White keeps the single signal where it belongs. Toggled with the
   active image by paint(). */
.bsp-stage--photo .bsp-rating,
.bsp-stage--photo .bsp-gal-count,
.bsp-stage--photo .bsp-gal-arrow {
  text-shadow: 0 1px 4px rgba(0, 0, 0, .55);
}
.bsp-stage--photo .bsp-rating-score { color: #FCFCFD; }
.bsp-stage--photo .bsp-rating-count { color: rgba(252, 252, 253, .82); }
/* the hover pair is defined further down the file and would win on source order,
   so these carry the extra class to outrank it rather than relying on position */
.bsp-stage--photo .bsp-rating:hover .bsp-rating-score { color: #FCFCFD; }
.bsp-stage--photo .bsp-rating:hover .bsp-rating-count { color: #FCFCFD; }
.bsp-stage--photo .bsp-rating-on { color: #FCFCFD; }
.bsp-stage--photo .bsp-rating-off { color: rgba(252, 252, 253, .34); }
/* the stars are SVG fills, so they take a drop-shadow filter rather than the
   text-shadow above — same value, same soft hold against a light patch */
.bsp-stage--photo .bsp-rating-stars,
.bsp-stage--photo .bsp-gal-arrow svg {
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .5));
}
.bsp-stage--photo .bsp-gal-count { color: rgba(252, 252, 253, .8); }
.bsp-stage--photo .bsp-gal-arrow { color: rgba(252, 252, 253, .85); }
.bsp-stage--photo .bsp-gal-arrow:hover { color: #FCFCFD; }
/* ---- Add-on preview chip (landing's .bsx-addon-preview, inverted) ----------
   Named when the render can't depict a selection — e.g. Halo is shown with its
   lightseal while a Custom-Fit Cushion is also in the basket. The landing's chip
   is dark because its stage is dark; the store plate is #F4F3F3, so this one is
   a white plate with a hairline. Bevel corners, not the reference's circle: the
   store has no circles left — even the swatches are bevelled. */
/* Tucked into the headset's lower-right corner, inside the product box — the
   chip has to read as "this build, plus this" and the plate corner was too far
   away to say that. --bsp-addon-y is set per render by swapRender(): the
   right-hand silhouette bottoms out at 71% of the frame on a soft strap and 78%
   with halo + audio strap, so the chip rides with the hardware instead of
   parking at the worst case. It grows DOWNWARD, out of the box and into the
   plate's floor, which is where the room for a second chip is (a pair is ~116px;
   the box leaves only ~70px under the deepest render). */
.bsp-addon-stack {
  position: absolute;
  top: var(--bsp-addon-y, 74%); right: 0;
  z-index: 2;
  display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
  pointer-events: none;
  transition: top 600ms var(--ease-signature);
}
@media (prefers-reduced-motion: reduce) {
  .bsp-addon-stack { transition: none; }
}
/* Type, not a card. This was a white plate with a hairline, a bevel and a 72px
   thumbnail — a second card sitting on the stage, competing with the product for
   attention it doesn't deserve: it is a footnote to the render, not an object on
   it. One line of small mono says the same thing and lets the hardware keep the
   plate. The name takes the violet (it marks something actively chosen, same
   signal as the checked option cards it mirrors) and the reason drops to the
   plate's muted ink, so the pair reads [what] then [why] rather than as two
   equal claims. */
.bsp-addon-preview {
  display: flex; align-items: baseline; gap: 8px;
  opacity: 0; visibility: hidden;
  transform: translateY(10px);
  /* visibility waits out the fade so the hidden chip can't be tabbed or hovered */
  transition: opacity 400ms var(--ease-signature),
              transform 500ms var(--ease-signature),
              visibility 0s linear 500ms;
  pointer-events: none;
}
.bsp-addon-preview.is-on { opacity: 1; visibility: visible; transform: none; transition-delay: 0s; }
/* The thumbnail goes with the card. Beside a full-size render of the headset, a
   72px picture of a cushion is a second photograph asking to be looked at, for a
   line whose whole job is to name the thing the first photograph can't show. */
.bsp-addon-thumb { display: none; }
/* name and reason on one line, sharing a baseline */
.bsp-addon-txt { display: flex; flex-direction: row; align-items: baseline; gap: 8px; }
.bsp-addon-t {
  font-family: var(--font-mono); text-transform: uppercase;
  font-size: 12px; line-height: 16px; letter-spacing: .06em;
  color: var(--brand); white-space: nowrap;
}
/* the typed plus replaces the drawn cross: at this size a 20px glyph box was
   larger than the type it introduced */
.bsp-addon-t::before { content: "+ "; }
.bsp-addon-note {
  font-family: var(--font-mono); text-transform: uppercase;
  font-size: 11px; line-height: 14px; letter-spacing: .06em;
  color: var(--muted-on-plate); white-space: nowrap;
}
/* the drawn cross that used to lead the card — the typed "+" on .bsp-addon-t
   does that job now, on the text's own baseline */
.bsp-addon-plus { display: none; }
/* ---- VRChat Edition: the chip gets its plate back ---------------------------
   Bare type works because the plate under it is a calm #F4F3F3. The VRChat art
   panel is not calm: it is saturated cyan with near-white highlights, and the
   line crosses its right edge — at a 776px plate the chip runs x401→736 while
   the art ends at 674, so ~270px of it is on the art and ~60px on the plate.
   Violet on cyan and 45% black on cyan both disappear there, and recolouring to
   white would only move the problem to the 60px hanging over the light plate.
   So on this edition only it is a plate again: white, bevelled, sized to the
   type. Every other configuration keeps the bare line.

   Fill only, no outline: a 1px border would draw a hard rectangle over the art
   and the plate both, and the fill already does the whole job — it reads as a
   white card where it crosses the art and dissolves into #F4F3F3 where it
   doesn't, which is exactly the amount of object this footnote deserves. */
.bsp-stage--vrchat .bsp-addon-preview {
  padding: 8px 12px;
  background: #fff;
  border-radius: 2px 2px 10px 2px;
  corner-shape: round round bevel round;
}
@media (prefers-reduced-motion: reduce) {
  .bsp-addon-preview { transition: opacity 400ms linear, visibility 0s linear 400ms; transform: none; }
}
/* ---- Mobile: the add-on readout belongs to the big plate only ---------------
   Same type treatment as the desktop rule above — it only has to move and to
   know when to leave. It lives in the plate's lower-left while the plate is at
   hero height, and fades out as the plate folds: the strip has room for the
   render and one line under it, and that line is "your configuration", which
   labels the whole thing rather than one part of it. Trying to keep both in a
   144px band meant the type running through the hardware whichever way it was
   stacked.
   -------------------------------------------------------------------------- */
@media (max-width: 991.98px) {
  .bsp-addon-stack {
    /* Bottom-left, on the tag's 12px axis. The frame is inset 16px from the
       plate and this is positioned against the frame, so -4px lands it on the
       plate's 12px — the correction is now a nudge inward rather than the 9%
       that used to be needed to claw back a frame wider than the plate. */
    top: auto; bottom: 12px;
    left: -4px; right: -4px;
    align-items: flex-start;
    flex-direction: column;
    gap: 4px;
    transition: none;
    /* goes with the rating, on the same value that drives the height */
    opacity: clamp(0, calc(1 - var(--bsp-p, 0) * 2.2), 1);
  }
  .bsp-addon-stack:has(> :nth-child(2)) {
    top: auto; bottom: 12px;
    flex-direction: column;
  }
  .bsp--folded .bsp-addon-stack { visibility: hidden; }
  .bsp-addon-preview { max-width: 100%; }
  /* Stacked here, unlike the desktop row above. Side by side the pair needs
     ~343px and the inset plate offers ~319, so the reason clipped mid-word
     ("ADDED AS A 2ND C…"). Vertical space is the one thing this has plenty of:
     it is only ever visible while the plate is at hero height, which leaves ~90px
     of plate under the render for two 16px lines. */
  .bsp-addon-txt {
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    min-width: 0;
  }
  /* still the guard for an unusually long add-on name, no longer the normal case */
  .bsp-addon-note {
    min-width: 0;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  /* tighter than the desktop plate: the inset phone plate offers ~319px and the
     note already has to ellipsize inside it */
  .bsp-stage--vrchat .bsp-addon-preview { padding: 6px 8px; }
}

/* model readout — Beyond only (see the section's markup: it names the SELECTED
   model as the render swaps; other products don't render it) */
.bsp-stage-caption {
  position: absolute;
  left: 24px; bottom: 20px;
  font-family: var(--font-mono);
  font-size: 12px; line-height: 16px;   /* was inheriting 22px */
  letter-spacing: .06em; text-transform: uppercase;
  color: var(--muted-on-plate);
}
/* Not on mobile. It named the selected model, which the option card immediately
   below already does — and on the VRChat build it wrapped to two lines, making
   the quietest thing on the plate the loudest. The folded strip gets
   .bsp-stage-tag in this corner instead: a label for the strip itself rather
   than a restatement of the model. */
@media (max-width: 991.98px) {
  .bsp-stage-caption { display: none; }
}

/* ---- "Your configuration" — the folded strip's own label --------------------
   Fades IN as the plate closes, the mirror of the rating fading out. At hero
   height the plate is unmistakably the product and needs no caption; once it is
   a 132px band the small render wants naming, because by then it is no longer a
   hero shot but a running summary of what the visitor has built. Same corner and
   same mono register the model caption used to hold. */
.bsp-stage-tag { display: none; }
@media (max-width: 991.98px) {
  .bsp-stage-tag {
    display: block;
    position: absolute;
    /* the plate's lower-left, on the same axis as everything else on the page */
    left: 12px; bottom: 8px;
    font-family: var(--font-mono);
    /* 10px: this is the quietest thing on the page — a state label for a strip
       whose content speaks for itself — and it shares its corner with renders
       that reach into it (the Halo Mount build fills its frame far more than the
       soft-strap one). Below the system's 12px label floor, which the add-on
       lines here already precedent at 11 and 10. */
    font-size: 10px; line-height: 13px;
    letter-spacing: .08em; text-transform: uppercase;
    color: var(--muted-on-plate);
    pointer-events: none;
    /* arrives over the back half of the travel, so it does not cross-fade with
       the rating on its way out */
    opacity: clamp(0, calc(var(--bsp-p, 0) * 2.5 - 1), 1);
  }
}
/* scroll hint — the landing hero recipe on the light stage */
.bsp-scroll-hint {
  position: absolute;
  left: 50%; bottom: 20px;
  transform: translateX(-50%);
  display: flex; align-items: center; gap: 8px;
  font-family: var(--font-mono);
  font-size: 12px; line-height: 16px;   /* 13px was off-scale */
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--muted-on-plate);
  pointer-events: none;
  transition: opacity 400ms ease;
}
.bsp-scroll-hint span { animation: bspScrollBounce 1.8s ease-in-out infinite; }
.bsp-scroll-hint.is-gone { opacity: 0; }
/* the hint used to be suppressed while an add-on chip was up, because both sat
   on the plate floor. The chips now hang off the product box instead, so the
   floor is the hint's alone and it can stay. */
@keyframes bspScrollBounce {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
  .bsp-scroll-hint span { animation: none; }
}
/* "SCROLL ↓" is a desktop cinematic touch, and on a phone it lands on the same
   375px plate floor as the model caption — the two overlap into one unreadable
   run of mono. The config panel below is already visibly cut off by the fold, so
   the affordance is making itself, and the hint is the accessory to drop.
   It has to sit HERE, after the base rule: both selectors are one class deep, so
   this is decided on source order, and the copy of this rule that lived up in
   the stage block silently lost to the display:flex above. */
@media (max-width: 991.98px) {
  .bsp-scroll-hint { display: none; }
}

/* ---- Photo gallery controls (accessories with more than one shot) ----------
   Bare glyphs at the plate edges and a mono counter on the floor — no button
   plates, no dots rail. The stage stays a lit bench with the product alone on
   it; the controls only surface enough to be found. The counter takes the
   floor's centre from the scroll hint, which is why the hint is not rendered
   on a gallery product. */
.bsp-gal-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: flex; align-items: center; justify-content: center;
  width: 44px; height: 44px;
  padding: 0;
  background: none;
  border: 0;
  color: rgba(0,0,0,.45);
  cursor: pointer;
  transition: color 200ms ease;
}
.bsp-gal-arrow:hover { color: #000; }
.bsp-gal-arrow:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.bsp-gal-arrow--prev { left: 12px; }
.bsp-gal-arrow--next { right: 12px; }
.bsp-gal-arrow svg { display: block; }
.bsp-gal-count {
  position: absolute;
  left: 50%; bottom: 20px;
  /* same layer as the arrows. It got away with `auto` while every shot floated
     in the 82% box — the counter sat on empty plate and nothing overlapped it —
     but a full-bleed photograph reaches the floor, and .bsp-stage-inner's
     z-index: 1 then painted the picture straight over it. */
  z-index: 2;
  transform: translateX(-50%);
  margin: 0;
  font-family: var(--font-mono);
  font-size: 12px; line-height: 16px;
  letter-spacing: .12em;
  color: rgba(0,0,0,.45);
  pointer-events: none;
}
@media (max-width: 991.98px) {
  .bsp-gal-arrow--prev { left: 4px; }
  .bsp-gal-arrow--next { right: 4px; }
}

/* ---- Config panel (right): white, content fills the panel ---- */
.bsp-col {
  min-width: 0;
  display: flex;
  flex-direction: column;
  background: #fff;
}
.bsp-col-inner {
  width: 100%;
  padding: 0 28px 0;   /* top flush: the title starts level with the stage plate's top edge */
  box-sizing: border-box;
  flex: 1;
}
/* the kicker sets the top line, level with the plate's top edge */
.bsp-kicker {
  font-family: var(--font-mono);
  font-size: 15px; line-height: 1; letter-spacing: .1em; text-transform: uppercase;
  color: #70777A;
  margin: 4px 0 40px;
}
@media (max-width: 991.98px) {
  .bsp-col-inner { padding: 32px 16px 0; }
}

.bsp-title {
  /* 12, not 20: the rule sat too loose against both words, and 20 was off the
     8px scale anyway. Applies to the flat "— 2" and the gradient "— 2e" alike. */
  display: flex; align-items: center; gap: 12px;
  font-family: var(--font-display);
  font-weight: 400;                           /* 55 Roman — never medium/bold */
  /* Headline Medium: 32/40 mobile, 48/56 desktop */
  font-size: clamp(32px, 3.4vw, 48px);
  line-height: clamp(40px, 3.9vw, 56px); letter-spacing: -.015em;
  margin: 0 0 16px;
}
.bsp-title-rule {
  flex: 0 0 auto; width: 64px; height: 4px;
  background: var(--brand);
  transition: background var(--dur-slow) var(--ease-signature);
}
/* The word space the rule stands in for. The rule is an empty decorative span,
   so the two halves of the lockup ran together for anything reading the text
   rather than the picture: the accessible name came out "Bigscreen Beyond2", and
   so did the heading if you selected and copied it. This span holds a real
   space. Absolutely positioned so it is not a flex item at all — it adds no
   track, no gap and no width — while staying a live text node in the document. */
.bsp-title-sp { position: absolute; width: 0; overflow: hidden; }
/* One line on a phone, same as everywhere else — "Bigscreen Beyond ▬ 2" is a
   wordmark and breaking it makes it read as two things.

   It briefly wrapped by design: the name was given the full row so the rule and
   numeral dropped to a second line together, which was a fix for the name
   wrapping INSIDE its own flex track and leaving the rule stranded mid-height
   against a ragged edge. But that was solving the wrong problem — at 32px the
   whole lockup is ~320px against ~360px of rail, so it was never short of room;
   the forced break was the only thing splitting it.

   What keeps it honest on the narrowest phones is which element gives way. The
   name is set nowrap and the RULE is made shrinkable, so as the rail narrows the
   violet bar shortens instead of the words breaking — down to a 16px floor,
   which still holds the line together at 320px. */
@media (max-width: 640.98px) {
  /* Holding one line needs the LOCKUP to scale, not just the rule to shrink.
     A nowrap name plus a floored rule cannot get below ~317px, so on a 320px
     phone it simply ran through the rail and put horizontal scroll back on the
     whole document. Tying the size to the viewport lets the whole thing come
     down together — flat 32px from ~381px up, easing to ~27px at 320 — which
     keeps the wordmark intact where breaking or clipping it would not.
     Leading tracks it as a ratio so 32px still lands on the token's 40. */
  .bsp-title {
    gap: 10px;
    font-size: clamp(26px, 8.4vw, 32px);
    line-height: 1.25;
  }
  .bsp-title > span:first-child { flex: 0 0 auto; white-space: nowrap; }
  /* the violet bar is what gives first, before the type has to */
  .bsp-title-rule { flex: 0 1 40px; width: auto; min-width: 16px; }
}
/* Eyetracking models: the rule takes the landing's "Ready to go Beyond?" b2e
   treatment (.mc-buy--b2e) — brand violet running out to magenta — while the
   base model keeps the flat brand violet (.mc-buy--b2). */
.bsp-title--e .bsp-title-rule {
  background: linear-gradient(90deg, #4D1EF7 15%, #8050F5 52%, #B300FF 100%);
}
/* the numeral anchors the "e"; absolute so an absent "e" reserves no width and
   the "2" never shifts */
.bsp-title-num { position: relative; }
.bsp-title-e {
  position: absolute; left: 100%; top: 0;
  opacity: 0;
  transform: translateX(-.18em);
  transition: opacity var(--dur) ease, transform var(--dur-slow) var(--ease-signature);
  pointer-events: none;
}
.bsp-title--e .bsp-title-e { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  .bsp-title-rule, .bsp-title-e { transition: none; }
}
/* ---- Rating (metafield-driven; ours, not the Yotpo star widget) -------------
   On the STAGE, top-left, mirroring .bsp-stage-caption's 24/20 in the opposite
   corner — it was in the config rail first and read as clutter there, stacked
   between the title and the price ahead of four more blocks. The stage is also
   sticky, so here the rating holds the screen for the whole configurator flow.
   Sized to the stage's chrome register (12px mono, .06em, the caption's exact
   ink) rather than the rail's 15px, so it reads as instrumentation on the plate
   instead of a rail element that wandered left. Violet stays on the marks only;
   the type is the same black-at-45% the rest of the stage chrome uses, which
   keeps the plate from gaining a second signal colour. */
.bsp-rating {
  position: absolute;
  left: 24px; top: 20px;
  z-index: 3;                 /* over the VRChat backdrop panel and the render */
  display: inline-flex; align-items: center; gap: 8px;
  text-decoration: none;
  color: inherit;
}
.bsp-rating-stars { position: relative; display: inline-flex; }
/* max-content so the clipped fill row keeps its full five-star width instead of
   squeezing to whatever the clip leaves it */
.bsp-rating-set { display: flex; gap: 2px; width: max-content; }
/* the muted row is the one in normal flow — it sizes the box the fill clips against */
.bsp-rating-off { color: rgba(77, 30, 247, .25); }
.bsp-rating-on {
  position: absolute; left: 0; top: 0; height: 100%;
  width: var(--bsp-fill, 0%);
  overflow: hidden;
  color: var(--brand);
}
.bsp-rating-star { display: block; }
.bsp-rating-score,
.bsp-rating-count {
  font-family: var(--font-mono);
  font-size: 12px; line-height: 16px;   /* the stage chrome size, as .bsp-stage-caption */
  letter-spacing: .06em; text-transform: uppercase;   /* mono is always caps here */
}
.bsp-rating-score { color: rgba(0, 0, 0, .72); }
.bsp-rating-count { color: var(--muted-on-plate); }   /* exactly the caption's ink */
/* separator drawn rather than typed: a middot in the markup gets read out, and
   a square keeps the bevel language (the store has no circles left) */
.bsp-rating-count::before {
  content: ''; display: inline-block;
  width: 3px; height: 3px; margin-right: 8px; vertical-align: 2px;
  background: currentColor; opacity: .6;
}
.bsp-rating:hover .bsp-rating-score,
.bsp-rating:hover .bsp-rating-count { color: rgba(0, 0, 0, .85); }
.bsp-rating:hover .bsp-rating-count { text-decoration: underline; text-underline-offset: 3px; }
.bsp-rating:focus-visible { outline: 2px solid var(--brand); outline-offset: 4px; }

.bsp-from {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(22px, 1.8vw, 24px);  /* Body Large 24 / 22 */
  line-height: clamp(30px, 2.4vw, 32px);   /* 22/30 mobile · 24/32 desktop */
  margin: 0;
}
.bsp-from-price {
  /* same voice as the sentence — the Figma sets the price in Haas, not mono */
  font-family: var(--font-display);
  font-variant-numeric: tabular-nums;
  font-weight: 400;
}
.bsp-from-note {
  font-size: 15px; line-height: 22px;   /* Button/Link 15/22; was inheriting 27px */
  color: var(--bsp-muted);
  margin: 8px 0 0;                      /* 6px was off the 8px scale */
}
.bsp-desc {
  font-family: var(--font-display);
  font-weight: 400;                        /* 55 Roman */
  font-size: 18px; line-height: 24px;      /* Body Small 18/24 */
  letter-spacing: 0;
  color: #70777A;                          /* Figma fill */
  margin: 24px 0 0;
}

/* ---- Option groups ---- */
/* 40, not 56: with every group now made of full cards the rail reads as one
   continuous list, and 56px left the model picker stranded from the rest */
.bsp-group { margin-top: 40px; }
.bsp-group-head {
  font-family: var(--font-display);
  font-size: 24px; line-height: 32px;   /* Body Large 24/32 */
  font-weight: 400;
  letter-spacing: 0; text-transform: none;
  color: #0a0a0c;
  margin: 0 0 8px;
}
.bsp-group-desc {
  font-size: 16px; line-height: 20px;         /* Detail 16/20 */
  color: var(--bsp-muted);
  margin: 0 0 32px;                           /* 32 to the first option, per Figma */
}

/* option card (radio/checkbox rows) — the shipped store recipe: hairline box,
   #DFDFDF hover, violet inset when selected; bevel corners */
/* isolate, or the card's internals leave the card. The invisible input below is
   an inset:0 hit area at z-index 2, and .bsp-opt is only position:relative — no
   stacking context — so that 2 was being resolved against the whole column,
   where the sticky order footer sits at z-index auto. The input therefore
   painted OVER the footer, and hit-testing follows paint: on a short viewport,
   where the option list runs under the bar, a tap on ORDER NOW landed on
   whichever size happened to be behind it and silently changed the selection.
   Isolating keeps every level inside the card it belongs to (the same reason
   the VRChat Edition card's label isolates its band); the footer, later in the DOM, then
   wins on order alone. */
.bsp-opt { position: relative; isolation: isolate; margin-bottom: 12px; }
.bsp-opt input {
  position: absolute; inset: 0;
  opacity: 0; z-index: 2; cursor: pointer;
  margin: 0;
}
.bsp-opt input:disabled { cursor: default; }
.bsp-opt label {
  display: flex; align-items: center; gap: 16px;
  padding: 16px 18px;
  background: #fff;
  border: 1px solid var(--bsp-line);
  border-radius: 3px 3px 14px 3px;
  corner-shape: round round bevel round;
  transition: border-color var(--dur), background-color var(--dur);
}
.bsp-opt input:hover:not(:disabled):not(:checked) + label {
  background: var(--hairline);
}
.bsp-opt input:checked + label {
  border-color: var(--brand);
  box-shadow: inset 0 0 0 1px var(--brand);
}
.bsp-opt input:disabled:not(:checked) + label { opacity: .38; }
.bsp-opt input:focus-visible + label { outline: 2px solid var(--brand); outline-offset: 3px; }

.bsp-opt-thumb {
  flex: 0 0 72px; width: 72px; height: 72px;
  background: var(--off-white);
  border-radius: 2px 2px 8px 2px;
  corner-shape: round round bevel round;
  overflow: hidden;
}
.bsp-opt-thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
.bsp-opt-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.bsp-opt-body--inline { flex-direction: row; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.bsp-opt-title {
  font-family: var(--font-display);
  font-weight: 400; font-size: 18px; line-height: 24px;  /* 18/24 — shares the price's line box */
}
.bsp-opt-sub {
  font-size: 15px; line-height: 1.45;                    /* 15 */
  color: var(--bsp-muted);
}
/* Prices are Neue Haas, not Fragment Mono — Max's call, and an exception to the
   Caliper Rule that applies to every price in the configurator. Set at the
   title's exact 18/24 so the two sit on one line box and the price lands on the
   title's baseline; the muted colour carries the hierarchy instead of size. */
.bsp-opt-price {
  flex: 0 0 auto; align-self: flex-start;
  font-family: var(--font-display);
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  font-size: 18px; line-height: 24px;
  letter-spacing: 0; text-transform: none;
  color: var(--bsp-muted);
  white-space: nowrap;
}
.bsp-opt-price--included { color: var(--bsp-ink); }

/* ---- Option-card upgrades, ported from the landing configurator (home.html
   .bsx-card family). Store deltas: Roman 400 only, 18/24 type, store tokens.
   The check greys (#cfd0d4 / #c2c2c7) are the landing's own values. ---- */

/* visible check indicator, top-left over the card (the input overlay stays the
   click surface; this is presentation only) */
.bsp-opt-check {
  position: absolute; top: 12px; left: 12px;
  width: 22px; height: 22px;
  background: #fff;
  border: 1.5px solid #cfd0d4;
  border-radius: 2px 2px 7px 2px;
  corner-shape: round round bevel round;
  transition: background-color var(--dur), border-color var(--dur);
  pointer-events: none;
}
/* Thumb-less cards (apparel sizes, add-ons with no photo): nothing sits under
   the check, so the label's 18px padding leaves the title rendering behind the
   indicator. Clear it, and centre the check on the row — with no 72px thumb the
   card is one line tall and a top-pinned check reads visibly high. */
.bsp-opt label:not(:has(.bsp-opt-thumb)) { padding-left: 48px; }
.bsp-opt label:not(:has(.bsp-opt-thumb)) .bsp-opt-check { top: 50%; transform: translateY(-50%); }
/* except in the strap/cushion tree, where cards are tall and top-aligned */
.bsp-opts--tree .bsp-opt label:not(:has(.bsp-opt-thumb)) .bsp-opt-check { top: 12px; transform: none; }

.bsp-opt input:checked + label .bsp-opt-check { background: var(--brand); border-color: var(--brand); }
.bsp-opt input:checked + label .bsp-opt-check::after {
  content: ""; position: absolute; left: 7px; top: 3px;
  width: 5px; height: 10px;
  border: solid #fff; border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
/* No selection wash: the violet border and check already carry the signal, and
   a violet tint on every chosen card is the Single Signal Rule leaking. The
   landing uses one (#f8f6ff) because it sits on a dark stage; the store's cards
   are white on white and don't need it. */

/* locked card — included/forced options (Soft Strap; Universal cushion while
   Halo is on): wash stays, but neutral border and a grey check, so it reads
   "yours automatically", not "chosen" */
.bsp-opt--locked input:checked + label {
  border-color: var(--bsp-line);
  box-shadow: none;
}
.bsp-opt--locked input:checked + label .bsp-opt-check { background: #c2c2c7; border-color: #c2c2c7; }
.bsp-opt--locked input { cursor: default; }

/* ---- Sold out ------------------------------------------------------------
   An option that exists but is gone stays on the page. Pulling it out of the
   list reads as "we never made that size" and sends the visitor looking for it
   somewhere else; showing it greyed is what the old store theme did and what
   the shopper already understands.
   Deliberately NOT the .38 wash the data-disabled cards use above: that wash's
   job is to make an option recede until it's barely there, and this one has to
   be READ — the row still has to say which size it is and that it's gone. So
   the card steps back to the frame's grey instead, and spends its price slot on
   the word. Grey alone would be ambiguous on a card with no photo, where it
   could as easily read "not chosen yet". */
.bsp-opt--soldout input:disabled + label,
.bsp-opt--soldout input:disabled:not(:checked) + label {
  opacity: 1;
  background: var(--off-white);
  border-color: var(--bsp-line);
  box-shadow: none;
}
.bsp-opt--soldout .bsp-opt-title,
.bsp-opt--soldout .bsp-opt-sub,
.bsp-opt--soldout .bsp-opt-chip { color: var(--bsp-muted); }
.bsp-opt--soldout .bsp-opt-thumb { background: transparent; }
.bsp-opt--soldout .bsp-opt-thumb img { filter: grayscale(1); opacity: .5; }
.bsp-opt--soldout .bsp-opt-check { opacity: .35; }
/* a status, not a price — so it takes the store's label voice (mono, uppercase)
   rather than the Neue Haas the price column is set in */
.bsp-opt-price--soldout {
  font-family: var(--font-mono);
  font-size: 12px; letter-spacing: .06em; text-transform: uppercase;
  color: var(--bsp-muted);
}

/* ---- Link card: an option that lives on another page ----------------------
   Prescription lenses are ordered on their own page (LensAdvizor only runs
   there), so this card is an <a>, not a choice. Same shape as the option cards
   but no check indicator — nothing is being selected — and a diagonal arrow so
   it reads as leaving before it's clicked. */
.bsp-opt--link {
  display: flex; align-items: center; gap: 16px;
  padding: 16px 18px;
  background: #fff;
  border: 1px solid var(--bsp-line);
  border-radius: 3px 3px 14px 3px;
  corner-shape: round round bevel round;
  text-decoration: none; color: inherit;
  transition: border-color var(--dur), background-color var(--dur);
}
.bsp-opt--link:hover { background: var(--hairline); }
.bsp-opt--link:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; }
/* confirmed state, once the cart says it's actually in there */
.bsp-opt--link.is-in-cart {
  border-color: var(--brand);
  box-shadow: inset 0 0 0 1px var(--brand);
}
.bsp-opt-ext {
  width: 16px; height: 16px; flex: 0 0 auto;
  color: #70777A;
  transition: transform var(--dur) var(--ease-signature), color var(--dur);
}
.bsp-opt--link:hover .bsp-opt-ext { color: var(--brand); transform: translate(2px, -2px); }
@media (prefers-reduced-motion: reduce) { .bsp-opt-ext { transition: color var(--dur); } }

/* callout chip ("Best personal fit" / "Included" / ...) */
.bsp-opt-chip {
  align-self: flex-start;
  margin-top: 4px;
  padding: 6px 13px;
  font-size: 12px; line-height: 16px;
  background: var(--off-white); color: var(--muted-on-plate);
  border-radius: 2px 2px 9px 2px;
  corner-shape: round round bevel round;
}
.bsp-opt-chip--accent { background: var(--brand); color: #fff; }

/* ---- LensAdvizor: the prescription-type card -------------------------------
   The app offers one prescription type and auto-selects it, which hands the
   card straight to its own rule for the chosen state: white text, no border,
   and an accent fill that the app never injects for this selector (it injects
   one for its buttons, its nav pills and its lens cards, but not here). On the
   modal's white surface that leaves an invisible card — nothing to read and
   nothing that looks clickable — and since the visitor still has to click it to
   reach the prescription form, step one of the flow looks like a dead end.
   Paint the fill that state was written for, in the same black the app injects
   for its own buttons. Skin only: the markup and the behaviour stay the app's,
   and the grey description line inside the card still clears contrast on it. */
.la-modal-wrapper .prescription-type input[type="radio"]:checked + label {
  background-color: #000;
}

/* ---- Strap tree: straps side by side, bracket, halo add-on full width ---- */
/* minmax(0,1fr): plain 1fr lets the wordier card's min-content skew the split */
.bsp-opts--tree { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.bsp-opts--tree .bsp-opt { margin-bottom: 0; }
.bsp-opts--tree .bsp-opt label { height: 100%; box-sizing: border-box; align-items: flex-start; }
/* Half-width cards are ~280px: thumb (72) + gap + a nowrap price (~110) left the
   copy about 45px, which shredded "Audio Strap" into one word per line. In the
   tree only, the price wraps onto its own full-width row under the copy and the
   thumb steps down, so the title and description get the full column. */
.bsp-opts--tree .bsp-opt:not(.bsp-opt--wide) label { flex-wrap: wrap; row-gap: 10px; }
.bsp-opts--tree .bsp-opt:not(.bsp-opt--wide) .bsp-opt-thumb { flex: 0 0 56px; width: 56px; height: 56px; }
.bsp-opts--tree .bsp-opt:not(.bsp-opt--wide) .bsp-opt-price {
  flex: 1 0 100%;
  align-self: auto;
  text-align: right;
}
.bsp-opt--wide { grid-column: 1 / -1; }
.bsp-opt--wide label { align-items: center; }
.bsp-tree-link { grid-column: 1 / -1; position: relative; height: 24px; margin: -2px 0; }
.bsp-tree-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.bsp-tree-svg path { fill: none; stroke: var(--hairline); stroke-width: 1; vector-effect: non-scaling-stroke; stroke-linecap: round; }

/* the "halo includes the universal cushion" note (landing .bsx-notice) */
.bsp-halo-note {
  grid-column: 1 / -1;
  border: 1px solid var(--brand);
  border-radius: 2px 2px 12px 2px;
  corner-shape: round round bevel round;
  padding: 11px 15px;
  font-size: 15px; line-height: 22px;
  color: var(--brand);
  text-wrap: pretty;
}
.bsp-halo-note[hidden] { display: none; }

@media (max-width: 767px) {
  /* One column. Two ~170px cards cannot hold a thumb, copy and a price: the
     copy was being squeezed below its min-content and the whole group scrolled
     sideways. */
  .bsp-opts--tree { grid-template-columns: minmax(0, 1fr); }
  /* the bracket draws a two-column relationship — meaningless once stacked */
  .bsp-tree-link { display: none; }
  /* Price gets its own row on EVERY card here, not just the tree ones: a nowrap
     price ("Included with Halo Mount" is ~190px) alongside a 72px thumb leaves
     nothing for the title on a phone. */
  .bsp-opt label { flex-wrap: wrap; row-gap: 10px; }
  /* ...except the sold-out word, which is short by construction and would
     otherwise hand a dead option a taller card than a buyable one */
  .bsp-opt-price:not(.bsp-opt-price--soldout) { flex: 1 0 100%; align-self: auto; text-align: right; }
  /* The tree drops to one column here (rule above), so its cards are full width
     again and the 56px thumb it steps down to for half-width columns no longer
     has a reason — put it back on the 72px the cushion cards beside it use. */
  .bsp-opts--tree .bsp-opt:not(.bsp-opt--wide) .bsp-opt-thumb {
    flex: 0 0 72px; width: 72px; height: 72px;
  }

  /* The prescription card is an <a>, not a <label>, so the wrap rule above never
     reached it — while the flex-basis:100% price rule did. A 100%-wide item in a
     NOWRAP row cannot wrap, so it simply widened the row past the viewport: the
     copy column collapsed to one word per line and the whole DOCUMENT gained
     ~100px of horizontal scroll. It needs the same wrap its sibling cards get.
     The basis leaves room for the 16px "opens elsewhere" arrow plus the row's
     16px gap, so price and arrow share the wrapped row instead of taking two. */
  .bsp-opt--link {
    flex-wrap: wrap; row-gap: 10px;
    /* the copy runs to six lines at this width, and a vertically centred thumb
       floats halfway down it instead of sitting with the title it illustrates */
    align-items: flex-start;
  }
  .bsp-opt--link .bsp-opt-price {
    flex: 1 0 calc(100% - 32px);
    align-self: center;
    text-align: right;
  }
  .bsp-opt--link .bsp-opt-ext { align-self: center; }
}

/* swatch row (cover shells) */
.bsp-swatches { display: flex; gap: 12px; }
.bsp-swatch { position: relative; isolation: isolate; }   /* same containment as .bsp-opt: the input's z-index stays in the chip */
.bsp-swatch input {
  position: absolute; inset: 0; opacity: 0; z-index: 2; cursor: pointer; margin: 0;
}
/* Soft plate, bevelled chip: the outer box is a solid off-white plate (fill
   only, no hairline; the same grey as the option thumb and the stage) and the
   store's corner cut lives on the colour chip inside it, with 5px of air (3px
   read as tight at this size). Both corners sit at the page's round-corner
   value (plate 3px, chip 2px like .bsp-opt-thumb): a concentric 8px plate was
   tried and reverted, because matching the option cards beside it matters more
   here than the 5px offset the padding would want. Selected = 2px violet ring
   inside the plate; hover = the plate steps to hairline grey. Mirrors the
   landing's .bsx-swatch. */
.bsp-swatch label {
  display: flex; align-items: center; justify-content: center;
  width: 48px; height: 48px;
  background: var(--off-white);
  border: 0;
  border-radius: 3px;
  padding: 5px; box-sizing: border-box;
  transition: background-color var(--dur), box-shadow var(--dur);
}
.bsp-swatch-chip {
  width: 100%; height: 100%;
  border-radius: 2px 2px 9px 2px;
  corner-shape: round round bevel round;
  border: 0;
}
.bsp-swatch input:hover:not(:checked) + label { background: var(--hairline); }
.bsp-swatch input:checked + label {
  box-shadow: inset 0 0 0 2px var(--brand);
}
.bsp-swatch input:focus-visible + label { outline: 2px solid var(--brand); outline-offset: 3px; }

/* Sold-out swatch. Greyscale alone would lie here — a desaturated chip just
   looks like a grey colourway — so the ring carries a slash through it and the
   colour is only dimmed, never renamed. Placed after the base swatch rules on
   purpose: both selectors weigh the same, so this only wins on order. */
.bsp-swatch--soldout label {
  position: relative;
  background: var(--off-white);
  box-shadow: none;
}
.bsp-swatch--soldout input:hover:not(:checked) + label { background: var(--off-white); }
.bsp-swatch--soldout .bsp-swatch-chip { filter: grayscale(1); opacity: .45; }
.bsp-swatch--soldout label::after {
  content: "";
  position: absolute; inset: 5px;
  background: linear-gradient(to top right,
    transparent calc(50% - 0.5px), var(--bsp-muted) calc(50% - 0.5px),
    var(--bsp-muted) calc(50% + 0.5px), transparent calc(50% + 0.5px));
}
.bsp-swatch--soldout input { cursor: default; }
/* Selected-colour line: name left, upcharge hard right, sitting ABOVE the
   swatch row. Same 18/24 line box as the option rows, so the name and the price
   share a baseline across the full width of the rail. */
.bsp-swatch-name {
  display: flex; align-items: baseline; justify-content: space-between; gap: 16px;
  margin: 0 0 16px;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 18px; line-height: 24px;
  letter-spacing: 0; text-transform: none;
  color: var(--bsp-muted);
}
.bsp-swatch-name strong { color: var(--bsp-ink); font-weight: 400; }
.bsp-swatch-name [data-swatch-price] {
  flex: 0 0 auto;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* fine print / footnotes */
.bsp-fine {
  margin: 16px 0 0;
  font-size: 15px; line-height: 22px;   /* Button/Link 15/22; 14px was off-scale */
  color: var(--bsp-muted);
}
/* Footnote markers are ONE fixed size wherever they land — 10px, the same as
   the live page's `body sup`. They are reference marks, not type, so they do
   not scale with whatever they hang off: left to the browser default (`smaller`
   ≈ 0.83em) the mark on an 18px card title rendered ~15px and read as part of
   the product name. Deliberately below the 12px label floor for the same
   reason — a marker is instrument notation, not a label.
   Raised by `top`, not by `vertical-align: super`: the default alignment grows
   the line box, so a card carrying a footnote stood 4px taller than one that
   did not — the marker was pushing the card, not riding it. Zero line-height
   plus a relative offset keeps the mark in the 24px box the title shares with
   the price. Live solves the same problem with `position: absolute`, which
   only works there because Builder positions the host span; staying in flow
   lets a long title wrap as one string with its marker attached. */
.bsp-opt-title sup,
.bsp-swatch-name sup,
.bsp-fine sup {
  font-size: 10px;
  line-height: 0;
  vertical-align: baseline;
  position: relative;
  top: -0.45em;
}

/* per-model info plate (store.info_box, e.g. the VRChat Edition box) —
   prominent tonal plate, fill only. Originally the upgrade-kit note. */
.bsp-note {
  margin-top: 16px;   /* 18 was off the 8px scale */
  padding: 32px;
  background: var(--off-white);
  /* the system bevel, at the story note's size — the plain 8px round was a
     Dawn-era leftover next to the beveled cards above it */
  border-radius: 3px 3px 18px 3px;
  corner-shape: round round bevel round;
  display: flex; flex-direction: column; gap: 14px;
}
/* the display:flex above beats the UA's [hidden] rule, so the unselected
   models' plates kept their 166px of flow — restate the contract */
.bsp-note[hidden] { display: none; }
/* 22ch was tuned for the two-line upgrade-kit lockup; the info_box copy runs
   ~50ch and needs the fuller measure to wrap in two lines, not four slivers */
.bsp-note-title { margin: 0; font-size: 24px; line-height: 32px; color: #000; max-width: 34ch; }
.bsp-note-link {
  display: inline-flex; align-items: center; gap: 10px;
  font-size: 18px; line-height: 24px;   /* Body Small 18/24 */
  color: #000; text-decoration: none; width: fit-content;
}
/* the site's stroked arrow (guide-support recipe), in violet */
.bsp-note-link .bsp-arrow {
  color: var(--brand);
  flex: 0 0 auto;
  transition: transform var(--dur) var(--ease-signature);
}
.bsp-note-link:hover .bsp-arrow { transform: translateX(4px); }

/* ---- Sticky summary footer (bottom of config panel) ---- */
.bsp-footer {
  position: sticky;
  bottom: 0;
  /* An overlay declares its level rather than inheriting one from DOM order: the
     bar covers the option list for the whole scroll, so anything in the rail
     that takes a z-index would otherwise be able to reach through it. 3 clears
     the option and swatch hit areas at 2; the mobile fixed rule below goes to 60
     to clear neighbouring sections. */
  z-index: 3;
  margin-top: 64px;
  background: #fff;
  border-top: 1px solid var(--hairline);
}
.bsp-footer-inner {
  padding: 20px 28px calc(20px + env(safe-area-inset-bottom));
  box-sizing: border-box;
  display: flex; flex-direction: column; gap: 20px;
}
.bsp-footer-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }
@media (max-width: 1200px) {
  .bsp-footer-inner { padding-inline: 24px; }
}
@media (max-width: 991.98px) {
  .bsp-footer-inner { padding-inline: 16px; }
}
.bsp-ship { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.bsp-ship-label {
  font-family: var(--font-display);
  font-size: 16px; line-height: 20px;   /* Detail 16/20 */
  color: #70777A;
}
.bsp-ship-msg {
  font-family: var(--font-display);
  font-size: 24px; line-height: 32px; color: #000;
}
.bsp-total {
  font-family: var(--font-display);
  font-variant-numeric: tabular-nums;
  font-size: 32px; line-height: 40px;
  color: #000;
  text-align: right;
  white-space: nowrap;
}
.bsp-order {
  display: flex; align-items: center; justify-content: center;
  width: 100%;
  min-height: 52px; padding: 0 30px;
  background: var(--brand); color: #fff;
  border: 0; cursor: pointer;
  border-radius: 2px 2px 14px 2px;
  corner-shape: round round bevel round;
  font-family: var(--font-mono);
  font-size: 15px; line-height: 22px;   /* Button/Link 15/22; was `normal` */
  letter-spacing: .08em; text-transform: uppercase;
  transition: background-color var(--dur), opacity var(--dur);
  -webkit-tap-highlight-color: transparent;
}
/* black, not --brand-deep — see the note on the drawer's checkout button in
   bs-cart.css; the order button and the checkout button answer the same way */
.bsp-order:hover:not(:disabled) { background: #000; }
.bsp-order:disabled { opacity: .45; cursor: default; }
.bsp-order:focus-visible { outline: 2px solid var(--bsp-ink); outline-offset: 3px; }

.bsp-error {
  margin: 12px 0 0;
  font-family: var(--font-mono);
  font-size: 14px; color: #c22525;
  text-transform: uppercase; letter-spacing: .04em;
}

/* ---- Mobile: the order bar outlives the configurator ------------------------
   The footer sticks to the bottom of the config column, so it leaves with the
   column — and on a phone the column ends at ~4,500px of a ~23,000px page. That
   left 80% of the page with no way to buy: the header's "Order now" is display:
   none below 992px (it lives in the hamburger), so a visitor who does exactly
   what this product expects — specs, then FAQ, then reviews — had to scroll back
   ~18,000px to act on it.

   So past the configurator the same footer goes fixed and compacts to one row.
   The same element, not a second bar: one order button with the handler already
   bound to it, one total that is already being kept correct. A duplicate would
   be two of each, and the copy that drifts is always the one you can't see.

   The column reserves the height it gives up (--bsp-footer-h, measured in the
   section script), so going fixed takes nothing out of the document and the page
   underneath does not jump — the same reason the stage's grid row is pinned.
   -------------------------------------------------------------------------- */
@media (max-width: 991.98px) {
  .bsp--buybar .bsp-footer {
    position: fixed;
    left: 0; right: 0; bottom: 0;
    /* under the chrome (70/100) and far under the nav overlay (9999), so the
       menu and the ticker both still cover it */
    z-index: 60;
    border-top: 1px solid var(--hairline);
  }
  /* The z-index above is not enough on its own. bs-chrome.css gives every
     section `position: relative; z-index: 1` to lift them off the prefooter
     art, which makes each one its own stacking context — so the footer's 60
     only ranks it INSIDE the configurator, and the reviews section, being a
     later sibling at the same z-index, painted straight over it. The bar looked
     transparent with review text running through it. The section itself has to
     be raised, and only while the bar is up.

     2, and no higher. The header sits in a section wrapper of its own at
     z-index 3, and the nav pill's z-index:100 is scoped INSIDE that — so
     anything here from 4 up outranks the entire chrome, not just its neighbours.
     At 60 the configurator's own fine print and stage render painted straight
     over the nav for the ~600px band where both were on screen, and the nav
     simply vanished. All this ever needed was to clear a sibling at 1. */
  main#MainContent > .shopify-section.bsp-has-buybar { z-index: 2; }
  .bsp--buybar .bsp-col { padding-bottom: var(--bsp-footer-h, 0px); }

  /* ---- The bar is compact for the WHOLE phone breakpoint, not just past the
     configurator. It used to change shape when it went fixed, which meant the
     column had to reserve the TALL (149px) height while only the 65px compact
     bar was ever on screen — 84px of the reserve was covering a version of the
     bar the reader never sees, and it read as a void between the fine print and
     the shipping band. One shape at this width: reserve == what is on screen.
     It also hands the option list those 84px back for the whole scroll, which
     is the squeeze the plate driver already fights.
     The scroll-gated part is now only WHERE the bar sits (fixed vs in-column),
     which is what --bsp-footer-h balances. ---- */
  .bsp-footer-inner {
    flex-direction: row;
    align-items: center;
    gap: 12px;
    padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
  }
  .bsp-footer-row {
    flex: 1; min-width: 0;
    flex-direction: column;
    align-items: flex-start;
    gap: 0;
  }
  /* "Estimated ship date" is the one thing that can go: the date itself still
     reads as a date, and the row has to earn its height back somewhere */
  .bsp-ship-label { display: none; }
  .bsp-ship-msg { font-size: 13px; line-height: 18px; color: #70777A; }
  .bsp-total { font-size: 18px; line-height: 24px; text-align: left; }
  .bsp-order {
    width: auto; flex: 0 0 auto;
    min-height: 44px;          /* the tap-target floor, not the 52px full button */
    padding: 0 22px;
  }
  /* The 64px that separated a stacked footer from the options is dead space
     under a one-row bar — and it was never covered by the reserve, because
     offsetHeight excludes margin. So going fixed dropped 64px of flow the
     column never gave back: the page shortened by exactly that much at the
     moment the bar engaged. Zero here makes the swap balance. */
  .bsp-footer { margin-top: 0; }
  /* Constant, not toggled with the bar: making it conditional would change the
     document height at the exact moment the bar appears. Costs nothing when the
     bar is up, because that is when the reader is at the bottom of the page. */
  body:has(#bsp) { padding-bottom: calc(64px + env(safe-area-inset-bottom)); }
}

@media (max-width: 640.98px) {
  .bsp-total { font-size: 18px; line-height: 24px; }   /* not the 40px the desktop 32px rule sets */
  .bsp-order { padding: 0 20px; font-size: 13px; }
  /* The footer is the other half of the phone's squeeze: it and the stage are
     both pinned, so every pixel here is a pixel the option list does not get.
     Padding and gap step 20 -> 16 and the ship readout drops from Body Large to
     Body Small, which is still the largest thing in the bar and still reads as
     the answer to the label above it. ~24px back, on-scale throughout. */
  .bsp-footer-inner {
    padding: 16px 16px calc(16px + env(safe-area-inset-bottom));
    gap: 16px;
  }
  .bsp-ship-msg { font-size: 18px; line-height: 24px; }
}

/* =============================================================================
   Product story (bs-product-story) — the below-the-fold bands from the Figma:
   USP row, advanced-optics band, micro-OLED band, 3-up features. Light surface,
   Neue Haas 55 Roman throughout, mono labels caps.
   ============================================================================= */
.bss {
  background: #fff; color: #0a0a0c;
  font-family: var(--font-display); font-weight: 400;
}
.bss-container {
  max-width: var(--desktopMaxWidth);
  margin-inline: auto;
  /* no bottom padding: the review/Q&A heading rows below own that rhythm */
  padding: 0 16px;
  box-sizing: border-box;
}

/* ---- USP row ----
   No bottom rule. There used to be one here, and it was doing a job the space
   already does: 136px of air sits between it and "Advanced optics", which is
   more than an entire mobile section rhythm. A rule inside a gap that large
   isn't separating two things, it's drawing the bottom edge of a box around the
   policy strip — the boxed-shipping-bar look this store is explicitly not.
   The other reason is that hairlines mean something specific on this page: 14
   of them run through the spec table and the FAQ as ROW structure, repeating
   and delimiting list items. A single boundary rule spends the same ink on a
   different job and weakens both readings. The 88/72 padding is off the 8px
   ramp (should be 80/80) but is left alone: it predates this and re-snapping it
   moves the whole band. */
.bss-usp {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 64px;
  /* 80, not the 88 this shipped with — 88 is off the 8px ramp, and DESIGN.md
     names it as the example of what not to ship. 80 is the nearest token and
     the documented section rhythm. (The 72 below is off-scale too; left alone
     because it sets the gap under this band, not the one being complained
     about.) */
  padding: 80px 0 72px;
}
.bss-usp-title {
  display: flex; align-items: center; gap: 10px;
  margin: 0 0 14px;
  font-size: 18px; line-height: 24px;   /* Body Small 18/24 */
  font-weight: 400; color: var(--brand);
}
/* the three source icons are drawn on different aspect ratios (shipping 15x13,
   return 12x11, warranty 16x16), so they're normalised on HEIGHT, not into a
   square box — matching cap height is what makes a mixed set read as one
   weight beside 18px text. Inlined in the section, so no request to wait on. */
.bss-usp-icon { height: 16px; width: auto; flex: 0 0 auto; }
.bss-usp-body { margin: 0; font-size: 18px; line-height: 24px; color: #70777A; max-width: 46ch; }

/* ---- bands ---- */
.bss-band { padding-top: 120px; }
/* the micro-OLED band sets its copy right, per the Figma */
.bss-band--right { text-align: right; }
.bss-band--right .bss-lede { margin-left: auto; }
/* 32px, matching .bss-media-grid: the spec row reads as part of the panel it
   measures, not as the next block down */
.bss-band--right .bss-media { aspect-ratio: 21 / 9; margin-bottom: 32px; }
/* 21:9 is a decision about a WIDE band: it keeps the block from getting too tall
   across a 1600px desktop column, where the letterbox is still ~690px of image.
   Against a 343px phone the same ratio is 147px — a strip in which the panel,
   already small and centred in its frame, is a speck between two black margins.
   Ratio is the wrong constant to hold across that much width; the SUBJECT's size
   is the thing worth holding, so the frame goes tall on a phone (147 -> 257) and
   the 16:9 source covers it. What the crop takes is the black either side of the
   panel, which is what the ratio was spending the height on in the first place. */
@media (max-width: 991.98px) {
  .bss-band--right .bss-media { aspect-ratio: 4 / 3; }
}
.bss-h2 {
  margin: 0 0 16px;
  /* Headline Medium: 32/40 mobile, 48/56 desktop (was a 40px max, off-token) */
  font-size: clamp(32px, 3.4vw, 48px); font-weight: 400;
  line-height: clamp(40px, 3.9vw, 56px); letter-spacing: -.01em;
}
.bss-lede {
  /* 32px to the media below — the same step .bss-media-grid and the optics
     slider use, so every band opens its imagery on one rhythm */
  margin: 0 0 32px;
  /* Both ENDS of the ramp are deck tokens now: Body Small 18/24 up to Body Large
     24/32. The max was 21/28, which is not a step on the scale — a fluid value
     that lands between two tokens is fine, a ceiling that parks there is not.
     The rendered size below ~1600px is unchanged; only the top of the ramp
     moves, so a very wide viewport gets a lede on the scale instead of near it. */
  font-size: clamp(18px, 1.5vw, 24px); line-height: clamp(24px, 2vw, 32px);
  color: #70777A; max-width: 62ch;
}
.bss-h3 {
  margin: 0 0 16px;
  /* Subhead: 26/32 mobile, 32/40 desktop */
  font-size: clamp(26px, 2.3vw, 32px); font-weight: 400;
  line-height: clamp(32px, 2.9vw, 40px); letter-spacing: -.01em;
}
.bss-body { margin: 0; font-size: clamp(18px, 1.5vw, 22px); line-height: clamp(24px, 2vw, 30px); color: #70777A; }
/* supporting copy that sits under a headline rather than carrying its own:
   held at Body Small so it stays subordinate to the h2 above the images */
.bss-body--sm { font-size: 18px; line-height: 24px; max-width: 62ch; }
.bss-media {
  position: relative;
  overflow: hidden;
  background: var(--off-white);
  border-radius: 3px 3px 18px 3px;
  corner-shape: round round bevel round;
}
.bss-media img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* the video fills its frame exactly like the stills it replaced; the poster is
   painted the same way so there's no jump when playback starts */
/* Keeps the band's 21:9 letterbox (Max's call — 16:9 made the block too tall).
   The 16:9 source therefore loses ~24% of its height to cover, but the panel is
   centred in frame so the crop takes empty bezel from the top and bottom. */
.bss-media--video { background: #000; }
.bss-video { display: block; width: 100%; height: 100%; object-fit: cover; }
/* hold the crop to the left edge instead of centring it — for wide renders whose
   subject sits at the far left (the IPD adjustment knob) */
.bss-media--anchor-left :is(img, video) { object-position: left center; }

.bss-media-grid {
  display: grid; grid-template-columns: 1fr 1.6fr; gap: 16px;
  margin-bottom: 32px;   /* on-scale: the copy row belongs to its media (homepage rhythm) */
}
.bss-media-grid .bss-media { aspect-ratio: 4 / 5; }
.bss-media-grid .bss-media + .bss-media { aspect-ratio: auto; }

/* ---- Optics slider ---------------------------------------------------------
   The two-up grid, made steppable: each slide holds BOTH panes so the pair
   always changes together. Controls ported from the landing's .bs-fslider
   (arrows, thumbnails, the slow Ken Burns drift) into the bss- namespace; the
   store deltas are the light plate and the bevel corner language.
   Slides stack in one grid cell so the block keeps a fixed height and nothing
   below it shifts as they cross-fade. */
/* the optics headline sits straight on the slider (no lede between), so it gets
   the 32px step instead of the 16px the h2 uses when copy follows it */
.bss-h2:has(+ .bss-slider) { margin-bottom: 32px; }
.bss-slider { position: relative; margin-bottom: 32px; }
.bss-slider-grid { display: grid; }
.bss-slide {
  grid-area: 1 / 1;
  display: grid; grid-template-columns: 1fr 1.6fr; gap: 16px;
  opacity: 0; visibility: hidden;
  transition: opacity 600ms cubic-bezier(.2, .8, .2, 1), visibility 0s linear 600ms;
}
.bss-slide.is-active { opacity: 1; visibility: visible; transition-delay: 0s; }
.bss-slide .bss-media { aspect-ratio: 4 / 5; }
.bss-slide .bss-media + .bss-media { aspect-ratio: auto; }
/* The image must NOT drive its pane's height. The right pane is aspect-ratio
   auto, so an in-flow img hands it the source's own ratio — and bs-optics-2b is
   portrait (1200x1800), which blew that pane to ~832px while the fixed 4/5 left
   pane stayed at 433. Taking the img out of flow leaves the pane with no
   intrinsic height, so it stretches to the row the left pane sets and
   object-fit: cover crops instead of resizing the box. */
.bss-slide .bss-media img { position: absolute; inset: 0; }
/* Focal point for the left pane. 3a and 4a are pre-cropped to 4:5 in the asset
   itself (a landscape source in this pane crops horizontally only, so CSS can
   neither lower the subject nor zoom past the full frame height) — they need
   nothing here. 2a is a portrait source, which this pane crops VERTICALLY, so
   its framing is still a CSS concern: this lifts him so his head sits level
   with the man in the right-hand pane. */
.bss-slide:nth-child(2) .bss-media:first-child img { object-position: 50% 0%; }
/* slow drift while a pair is on screen, each group toward its own focal point */
.bss-slide img { transform: scale(1.001); transition: transform 6.5s linear; will-change: transform; }
.bss-slide.is-active img { transform: scale(1.07); }
.bss-slide:nth-child(1) img { transform-origin: 40% 46%; }
.bss-slide:nth-child(2) img { transform-origin: 60% 42%; }
.bss-slide:nth-child(3) img { transform-origin: 46% 62%; }
.bss-slide:nth-child(4) img { transform-origin: 58% 50%; }

.bss-slider-arrow {
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 3;
  width: 52px; height: 52px;
  display: flex; align-items: center; justify-content: center;
  background: #fff; color: #0a0a0c; border: 0; cursor: pointer;
  border-radius: 2px 2px 12px 2px;
  corner-shape: round round bevel round;
  transition: background-color .15s cubic-bezier(.2, .8, .2, 1), color .15s, transform .12s;
}
.bss-slider-prev { left: 16px; }
.bss-slider-next { right: 16px; }
.bss-slider-arrow:hover { background: #0a0a0c; color: #fff; }
.bss-slider-arrow:active { transform: translateY(-50%) scale(.95); }
.bss-slider-arrow:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; }

/* The control row: pause button, then the thumbnail rail. The positioning used
   to live on .bss-slider-thumbs; it moved up one level when the pause button
   joined the row, because the button is not a tab and cannot sit inside the
   tablist that the thumbs form. */
.bss-slider-controls {
  position: absolute; left: 0; right: 0; bottom: 16px; z-index: 3;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  padding: 0 16px;
}
.bss-slider-thumbs { display: flex; gap: 8px; }
/* A tonal fill, no hairline: the thumbs are photographs and need the 1px edge to
   hold against whatever is under them, but this one is a solid control and the
   fill already separates it from the picture. */
.bss-slider-play {
  flex: 0 0 auto;
  width: 44px; height: 44px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: rgba(10, 10, 12, .58); color: #FCFCFD;
  border: 0; cursor: pointer;
  border-radius: 2px 2px 9px 2px;
  corner-shape: round round bevel round;
  transition: background-color .15s cubic-bezier(.2, .8, .2, 1);
}
.bss-slider-play:hover { background: rgba(10, 10, 12, .82); }
.bss-slider-play:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.bss-slider-play svg { display: block; }

.bss-thumb {
  position: relative;
  width: 72px; height: 44px; padding: 0; cursor: pointer; overflow: hidden;
  background: none; border: 1px solid rgba(255, 255, 255, .45); opacity: .55;
  border-radius: 2px 2px 9px 2px;
  corner-shape: round round bevel round;
  transition: opacity .15s cubic-bezier(.2, .8, .2, 1), border-color .15s;
}
.bss-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bss-thumb:hover { opacity: .85; }
.bss-thumb.is-active { opacity: 1; border-color: var(--brand); }
.bss-thumb:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

/* The slider kept its desktop two-up split all the way down: .bss-media-grid
   collapses to one column below 992 but the slider that replaced it never got
   the same rule, so a phone showed a ~127px pane beside a ~200px one with 42px
   arrows floating over both — two thumbnails pretending to be a gallery.
   One pane per slide instead, and specifically the FIRST one: that is the image
   the thumbnail rail below is cut from, so tapping a thumb still lands on the
   picture it shows. The detail shot it drops is a second view of the same
   subject, which is the cheapest thing on this band to spend. */
@media (max-width: 991.98px) {
  .bss-slide { grid-template-columns: 1fr; }
  .bss-slide .bss-media + .bss-media { display: none; }
}
@media (max-width: 640.98px) {
  .bss-slider-arrow { width: 44px; height: 44px; }   /* 42 was 2px under the touch floor */
  .bss-slider-prev { left: 10px; }
  .bss-slider-next { right: 10px; }
  /* Narrower but NOT shorter. These were 52x34, which is a 34px tap target on
     the one device that has no other way to reach them — the height stays at 44
     and the crop takes the difference. Four of them plus the pause button still
     land inside a 375px rail: 44 + 6 + (4x52 + 3x6) = 276. */
  .bss-thumb { width: 52px; height: 44px; }
  .bss-slider-controls { gap: 6px; bottom: 10px; padding: 0 10px; }
  .bss-slider-thumbs { gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
  .bss-slide, .bss-slide.is-active img { transition: none; transform: none !important; }
}

.bss-split-row {
  display: grid; grid-template-columns: 1.4fr 1fr; gap: 56px;
  /* start, not end: the copy has to begin on the same line as the hairline that
     opens the stat column. Bottom-aligning pushed it down the column and left
     the two blocks starting at unrelated heights. */
  align-items: start;
}
.bss-stat {
  display: flex; flex-direction: column; gap: 8px;
  border-top: 1px solid var(--hairline);
  padding-top: 16px;
}
.bss-stat-label {
  display: flex; align-items: center; gap: 8px;
  font-family: var(--font-mono);
  font-size: 15px; line-height: 16px;   /* Label Mono 15/16; was inheriting 27px */
  letter-spacing: .04em; text-transform: uppercase;
  color: #70777A;
}
.bss-stat-icon { width: 17px; height: 17px; flex: 0 0 auto; color: var(--brand); }
/* stepped down from clamp(64,6.6vw,96): the readout is "108° × 96°" now, not a
   bare "116°", so it needs ~2.5x the width in the same 1fr column */
/* Large: 36/42 mobile, 64/72 desktop */
.bss-stat-huge {
  /* caps by ~1333px so ordinary desktop widths sit on the 64/72 token itself,
     not on a fluid value a few px short of it */
  font-size: clamp(36px, 4.8vw, 64px);
  line-height: clamp(42px, 5.4vw, 72px); letter-spacing: -.02em;
  white-space: nowrap;

  /* The note under this number looked detached, and the box metrics said the
     opposite of what the eye saw: 12px of box gap below, 8px above. What the
     eye actually sees is INK, and "108° × 96°" is all digits and degree marks —
     1px of real descender inside a font box reserving 15px for one, plus 4px of
     half-leading. So 18px of empty space is baked in under the glyphs before
     the gap starts, and the note ended up sitting 26px off the number: the same
     optical distance as the number sits from its own FOV label. Three evenly
     spaced things read as three things, which is why the fine print floated
     instead of belonging to the figure above it.
     Pulled back to ~9px optical, so the group reads as [label] · [number + its
     footnote]. Set in em rather than px so it tracks the clamp: -0.2em is 13px
     against the 64px desktop size and 7px against the 36px mobile one, which is
     the same correction at both ends. (-0.25em was tried first and closed the
     ink gap to 5.5px, which reads as a collision rather than as a pair.) */
  margin-bottom: -.2em;
}
.bss-stat-note {
  /* 15/20 rather than 15/1.45 (21.75px): a fractional line box put the wrapped
     second line off the page's 4px rhythm on narrow screens */
  font-size: 15px; line-height: 20px;
  color: #70777A;
}
/* two shared rows (label, value) so every column's value sits on one baseline
   without any column reserving blank space it doesn't use — see .bss-spec */
.bss-specs {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  grid-template-rows: auto auto;
  gap: 32px;
  margin: 0;
  text-align: left;   /* the stat row stays left-set inside the right-aligned band */
}
/* hairline opens each column, then the labelled term, then the value — the
   landing's .oledb-spec order */
.bss-spec {
  display: grid;
  grid-row: span 2;
  grid-template-rows: subgrid;   /* label + value ride the shared rows above */
  row-gap: 12px;                 /* overrides the 32px column gutter */
  border-top: 1px solid var(--hairline);
  padding-top: 16px;   /* landing uses 14; 16 is the on-scale step */
}
/* Subhead (26/32 mobile, 32/40 desktop), not Headline Medium: four columns in
   this container are ~238px, and the longest value ("5120 × 2560") wraps at
   48px. The landing runs larger only because its columns are wider. */
.bss-spec-value {
  margin: 0;   /* the 12px label→value step is the subgrid row-gap */
  /* floor lands on Body Large 24/32 rather than an invented 26/32 — the top of
     the ramp was already the Subhead token, the bottom was not */
  font-size: clamp(24px, 2.3vw, 32px); line-height: clamp(32px, 2.9vw, 40px);
  letter-spacing: -.01em;
  color: #0a0a0c;
}
.bss-spec-label {
  display: flex; align-items: flex-start; gap: 8px;
  margin: 0;
  font-family: var(--font-mono);
  font-size: 15px; line-height: 16px;   /* Label Mono 15/16, as on the landing */
  letter-spacing: .04em; text-transform: uppercase;
  color: #70777A;
}
.bss-spec-icon { width: 15px; height: 15px; flex: 0 0 auto; margin-top: 1px; }

/* Two panels, asymmetric — was three equal columns. Equal thirds gave the band
   no entry point (three heroes side by side), and dropping to two columns would
   have made an aspect-ratio'd card TALLER, not shorter, because the columns get
   wider. Hence the fixed height below. 1.5fr/1fr is the same move as
   .bss-media-grid, so this is band language the section already owns.
   32px, not the 16px the image-only grids use: these columns carry body copy,
   and at 16px the paragraphs read as one run-on block. The other text-bearing
   rows in this section sit at 56-64px (.bss-usp, .bss-split-row); 32px is the
   on-scale step that separates the copy without floating the images apart. */
.bss-features { display: grid; grid-template-columns: 1.5fr 1fr; gap: 32px; }
/* One shared height rather than a per-card aspect ratio: the columns are
   different widths, so matching aspect would leave the two titles on unrelated
   baselines. The old 1/1 square resolved to ~517px at --desktopMaxWidth —
   taller than any other block in the section, including the FOV stat. The wide
   column letterboxes to ~2.4:1, which is the register .bss-band--right already
   uses for the OLED panel (21/9); the IPD render is centred in frame, so the
   crop takes empty backdrop from the top and bottom. */
.bss-feature .bss-media { height: clamp(240px, 24vw, 360px); margin-bottom: 24px; }
/* Body Large (24/32), NOT the Subhead the other h3s use: this band now opens
   with its own h2, so the card titles have to be subordinate to it. At 32/40
   they matched .bss-spec-value and read as section titles in their own right. */
.bss-feature .bss-h3 { font-size: 24px; line-height: 32px; }
/* fixed 18px, so it must restate the leading — otherwise it inherits .bss-body's
   clamp and renders 18/30 at wide viewports (this was the 18/27 Max caught) */
.bss-feature .bss-body { font-size: 18px; line-height: 24px; max-width: 52ch; }

/* The base-station requirement, demoted out of the card row: it's a purchase
   fact, not a differentiator, and at card weight it read as a third feature.
   Same hairline-over-a-mono-label construction as .bss-stat and .bss-spec —
   which means the label STACKS on the thing it labels. An earlier pass put it
   in a 160px column beside the title instead: that left the word ~190px from
   its own heading (so it labelled nothing, by proximity), and 160px is a grid
   line nothing else in this container uses — the features row is 1.5fr/1fr,
   the spec row is four equal columns, .bss-split-row is 1.4fr/1fr. An edge
   that aligns to nothing is exactly what "floating" looks like. Flush left,
   stacked, on the same axis as every other block in the band. */
/* A plate, not a hairline: the requirement is the one thing in this band that
   costs money, so it gets its own surface. A base-station render was tried in
   the plate's right-hand space and pulled — at the size the plate allows it
   read as a speck floating on its own drop shadow, and blowing it up would
   have made a purchase note heavier than the two features above it. The plate
   carries the weight on its own instead. Deliberately NOT a CTA — no price, no
   "shop base stations" button; that's the line where it becomes the upsell
   rail PRODUCT.md bans. Tonal step + bevel, per The Flat Stage Rule (no
   shadow). */
.bss-note {
  margin-top: 56px;
  padding: 32px;
  background: var(--off-white);
  border-radius: 3px 3px 18px 3px;
  corner-shape: round round bevel round;
}
.bss-note-label {
  display: block;
  margin-bottom: 12px;   /* the .bss-spec label-to-value step */
  font-family: var(--font-mono);
  font-size: 15px; line-height: 16px;   /* Label Mono 15/16 */
  letter-spacing: .04em; text-transform: uppercase;
  color: var(--muted-on-plate);
}
.bss-note-title { margin: 0 0 8px; font-size: 18px; line-height: 24px; font-weight: 400; }
/* the plate's grey, not the page's — this block sits on #F4F3F3 (see the token) */
.bss-note-body { margin: 0; font-size: 18px; line-height: 24px; color: var(--muted-on-plate); max-width: 62ch; }
/* Image tags — the store grid's .bsg-tag chip, sized up for the story cards.
   Difference from the grid: these sit on photography, not the grid's constant
   #F4F3F3 frame. The solid black carries the light shots (base station), and
   the white hairline keeps the chip's shape readable where the image goes
   near-black (the eyetracking macro) — without it the fill dissolves into the
   photo and only the text survives. Top-right: the bevel already owns the
   bottom-right corner, so the right edge reads as the card's metadata edge. */
.bss-chip {
  position: absolute; right: 16px; top: 16px;
  max-width: calc(100% - 32px);
  padding: 6px 11px;
  background: #000; color: #fff;
  border: 1px solid rgba(255, 255, 255, .22);
  font-family: var(--font-mono);
  font-size: 12px; line-height: 16px; letter-spacing: .06em; text-transform: uppercase;
  border-radius: 2px 2px 10px 2px;
  corner-shape: round round bevel round;
  white-space: nowrap; overflow: hidden;
}

@media (max-width: 991.98px) {
  /* The Compression Rule: large spacing steps down on mobile (80 -> 64). This
     override only ever reset the columns, so the band kept its desktop top
     padding on a phone — where it lands directly under the configurator's
     order-bar reserve and reads as one long void. */
  .bss-usp { grid-template-columns: 1fr; gap: 24px; padding-top: 64px; }
  .bss-band { padding-top: 80px; }
  /* The micro-OLED band sets its copy right because on desktop it is answering
     a left-set band across a wide column — the alignment is the composition. In
     one mobile column there is no counterweight left to answer, so all it does
     is give a four-line paragraph a ragged left edge and pull the only block on
     the page off the axis every other headline shares. */
  .bss-band--right { text-align: left; }
  .bss-band--right .bss-lede { margin-left: 0; }
  .bss-container { padding-bottom: 80px; }
  .bss-media-grid { grid-template-columns: 1fr; }
  .bss-media-grid .bss-media { aspect-ratio: 4 / 3; }
  .bss-split-row { grid-template-columns: 1fr; gap: 32px; }
  .bss-specs { grid-template-columns: repeat(2, 1fr); }
  /* stacked, so the shared-height trick has nothing left to solve — back to an
     aspect ratio, and 16/9 is the IPD render's native frame (no vertical crop) */
  .bss-features { grid-template-columns: 1fr; }
  .bss-feature .bss-media { height: auto; aspect-ratio: 16 / 9; }
  /* the render moves under the copy rather than shrinking beside it — at phone
     widths a side-by-side split leaves the paragraph about 20ch wide */
  .bss-note { margin-top: 48px; padding: 24px; }
}


/* VRChat Edition option card.

   The hook is DATA-DRIVEN: the liquid stamps the variant's
   store.selector_class metafield as data-style on .bsp-opt (the Builder page
   keyed the same value off data-handle), so the admin decides which card is
   the edition; this file only decides what the value "bs2e-vrc" looks like.

   UNSELECTED it is not styled at all: white plate, hairline, #DFDFDF hover,
   ink title, black mark — pixel-for-pixel the Beyond 2 and 2e cards. The blue
   artwork band used to run on the resting card, which made the third (and most
   expensive) model the loudest thing in the picker: an upsell banner sitting
   in a list of equals. A greyed-down version of the same art was tried as a
   middle ground and pulled too — the source is a pre-tinted wash with the
   avatars already ghosted into it, so desaturating it produced a 44-level band
   around L=211, i.e. a smudge, and any wash at all still weighted one row of
   three. So the artwork is now purely the SELECTED state. */
.bsp-opt[data-style="bs2e-vrc"] label {
  position: relative;
  overflow: hidden;      /* clips the band to the plate, bevel included */
  isolation: isolate;    /* keeps the band strictly under the row's content */
}
/* Selected: the edition gets its band. It rides a pseudo-element rather than
   the label's own background so the resting card keeps the base recipe's
   background entirely — including the #DFDFDF hover — with nothing to undo.
   The violet border + check from the base rule stay on, so selection reads the
   same here as on every other card in the configurator. */
.bsp-opt[data-style="bs2e-vrc"] input:checked + label::before {
  content: "";
  position: absolute; inset: 0; z-index: 0;
  background-image: linear-gradient(90deg, rgba(37, 84, 199, 0.95) 30%, rgba(31, 158, 205, 0.55)), url('bs-vrchat-bg.webp');
  background-size: cover, cover;
  background-position: center, right center;
  pointer-events: none;
}
/* Lifts the row's content off the band. Flex items and the absolute check take
   z-index without needing position — but they must NOT share a level: at a
   common z-index the later sibling wins, so a blanket `label > *` put the
   thumb on top of the check and the indicator vanished behind the photo. */
.bsp-opt[data-style="bs2e-vrc"] label > * { z-index: 1; }
.bsp-opt[data-style="bs2e-vrc"] .bsp-opt-check { z-index: 2; }

.bsp-opt[data-style="bs2e-vrc"] input:checked + label .bsp-opt-title,
.bsp-opt[data-style="bs2e-vrc"] input:checked + label .bsp-opt-sub,
.bsp-opt[data-style="bs2e-vrc"] input:checked + label .bsp-opt-price { color: #fff; }

/* The mark sits inline where the word "VRChat" would be — as INLINE text, not
   as a flex item. `inline-flex` on the title put the mark and "Edition" in
   their own flex tracks, so on the narrow rail the name wrapped to two lines
   inside its own track while the mark stayed pinned beside it, mid-height.
   Plain inline flow lets the whole title wrap like the other two cards'. The
   liquid splits the variant name on the word "VRChat", so the surrounding
   spaces are already in the string — no letter-spacing or gap needed.

   Recoloured with `brightness(0)`, the same one-liner .bsp-stage-vrc uses:
   VRC_white.svg is white artwork on transparency, so that lands it black with
   its alpha intact. It is drawn at the file's own proportions, full stop. A
   pass that cropped the mask to the ink and rescaled it to the cap height was
   reverted: the logo is an OUTLINED bubble whose strokes are ~1% of its width,
   so shrinking it drove them below a pixel and the whole mark bloomed into a
   filled-looking blob. It needs its native size to stay legible. */
.bsp-opt-vrc {
  display: inline-block;
  vertical-align: -5px;        /* stands the bubble on the text baseline, tail below */
  height: 20px; width: auto;
  filter: brightness(0);
}
.bsp-opt[data-style="bs2e-vrc"] input:checked + label .bsp-opt-vrc { filter: none; }   /* white on the band */


/* =============================================================================
   Yotpo widgets (reviews + Q&A). Third-party markup, so this is a light skin:
   contain them to the page grid and align their controls with the system.
   Kept deliberately shallow — deep overrides break when Yotpo ships new DOM.
   ============================================================================= */
.shopify-section:has(.yotpo-widget-instance) {
  max-width: var(--desktopMaxWidth);
  margin: 0 auto !important;   /* Dawn's app-section margins stacked ~120px here */
  padding-inline: 16px;
  box-sizing: border-box;
}
/* the reviews widget carries its own page-width padding, the Q&A one doesn't —
   neutralise it so both align on the same content edge as the story bands */
.shopify-section:has(.yotpo-widget-instance) .page-width {
  max-width: none !important;
  padding-inline: 0 !important;
}
/* heading rows sit in the same container as the story bands */
.bss-widget-head { padding-top: 80px; }   /* on-scale section rhythm */
.bss-widget-head .bss-h2 { margin: 0 0 24px; }

/* ---- Mobile: close the gap between the heading and the widget it names -------
   These heading rows carried 80px of padding on BOTH sides, and the widget below
   then opened with a 40px empty strip of its own (.yotpo-head has a min-height
   set from the template's custom_css). Stacked with the h2's own 24px margin,
   that put ~174px of nothing between the word "Reviews" and the score it belongs
   to — on a phone the heading scrolled off the top before its own widget
   arrived, so the two never read as one section.

   The heading keeps a section's worth of air above it and gives up the rest: the
   rhythm below a heading is the widget's business, not a second gap.

   The widget's own 40px strip is NOT touched from here, and cannot be: the
   template's custom_css compiles to `#shopify-section-… div .yotpo-head`, and an
   ID beats any selector a stylesheet can write, !important on both sides or not.
   It is already Yotpo's 100px default cut down to 40, and 16 + 40 reads as a
   reasonable step under a heading, so it is left alone — change it in
   templates/product.shop-bs2.json if it ever needs to go.
   -------------------------------------------------------------------------- */
@media (max-width: 991.98px) {
  .bss-widget-head { padding-top: 48px; padding-bottom: 0; }
  .bss-widget-head .bss-h2 { margin-bottom: 16px; }
}

/* A product with no reviews / no questions still mounts the widget, but at zero
   height — so the widget section costs nothing and the heading above it is left
   stranded over its own 80px of air. bs-yotpo-empty.js measures the widget and
   stamps this on the heading section only (the widget section has to stay in
   flow for the observer to see it come back). The class is only ever added by
   that script, so if it never runs nothing here applies. */
.shopify-section.bss-yt-empty { display: none; }

/* controls: bevel + Haas, matching every other button on the page.
   The reviews widget's "Write a review" button ships as .yotpo-new-review-btn
   (#yotpo-main-widget-btn) — the older [class*="write-review-btn"] name it used
   to carry is gone, which is why that button reverted to Yotpo's 100px pill.
   These two ran mono/uppercase like .bsp-order until the widget rebuild; they
   read as the loudest thing in the section at that treatment, so the mono comes
   off and they run Haas sentence-case instead. 14px is set explicitly on the
   group rather than left to Yotpo, which sizes the two buttons off different
   defaults — that's what let them drift apart in the first place. Same size
   Yotpo gives its own "Load more reviews" and "Read more", so the whole widget
   lands on one type size. Mono stays where it belongs elsewhere on the page.
   The :not(...wrapper) matters: Yotpo wraps the Q&A button in
   .yotpo-ask-question-btn-wrapper, which the substring selector also matched,
   so the 24px inline padding landed on the wrapper too. On desktop that
   wrapper is absolutely pinned to right:0 of the header, so its padding sat
   between the button and the content edge and held the button 24px short of
   the rule and the cards below it. */
.yotpo-widget-instance .yotpo-question-btn,
.yotpo-widget-instance .yotpo-new-review-btn,
.yotpo-widget-instance [class*="write-review-btn"]:not([class*="wrapper"]),
.yotpo-widget-instance [class*="ask-question-btn"]:not([class*="wrapper"]) {
  border-radius: 2px 2px 12px 2px !important;
  corner-shape: round round bevel round;
  font-family: var(--font-display) !important;
  font-size: 14px !important;
  letter-spacing: normal !important;
  text-transform: none !important;
  /* Yotpo runs these tight — 12px inline on the review button, none at all on
     the Q&A one, which only looked padded because it carries a fixed 140px
     width. On scale, and enough that the label clears the bevelled corner.
     width:auto releases that fixed 140 so the padding is what sets the button's
     size on both: against a border-box 140 the Q&A label had 92px of content
     box to sit in and only ~7px of slack, so a longer localisation would have
     wrapped inside a button that looked fine in English. */
  padding-inline: 24px !important;
  width: auto !important;
  box-sizing: border-box;
}
/* ...and the same box. With the type equalised above, the review button still
   sat taller: Yotpo sizes it off a 37px min-height and a 120px min-width where
   the Q&A one just runs 30px tall and hugs its label. Matched to the Q&A
   metrics; the width comes from the label plus the 24px inline padding set
   above. */
.yotpo-widget-instance .yotpo-new-review-btn {
  height: 30px !important;
  min-height: 0 !important;
  min-width: 0 !important;
}
/* "Load more reviews" ships as a 5px-radius rounded rect — the one remaining
   default-Shopify corner in the section. Same bevel as the controls above.
   .yotpo-vertical-pagination-item is the load-more variant specifically; the
   numbered .yotpo-pagination-item pills in the Q&A widget stay round. */
.yotpo-widget-instance .yotpo-vertical-pagination-item {
  border-radius: 2px 2px 12px 2px !important;
  corner-shape: round round bevel round;
}

/* ---- Mobile: reviews lose the card and take the full width -------------------
   Each review ships in a rounded, bordered, inset box. On a 375px phone that
   box costs ~40px of width to its own padding and frame, and stacks a dozen
   outlines down the page — a boxed-list look this store is explicitly not (the
   whole page separates by hairline and tonal step, never by a drawn box around
   every item). So on mobile the card comes off: no border, no radius, no inset
   padding, and the copy runs the full 16px container like everything else.

   A hairline BETWEEN reviews replaces it — the page's own row device (the spec
   table and FAQ use the same one), so the reviews still read as discrete
   entries without each being fenced. First one has no top rule; the widget's own
   heading rule already closes the block above it.
   -------------------------------------------------------------------------- */

/* The review-form lightbox opened pinned to the top-left of the viewport instead
   of centred. Yotpo centres it with `.modal-backdrop[data-v-…] { display: flex;
   place-items: center }`, but the EG QuickView app section
   (sections/eg-quick-view-modal.liquid) inlines a chunk of Bootstrap containing
   an unscoped `.modal-backdrop { display: block !important }` — same generic
   class name, and the !important beats Yotpo's flex. The backdrop stayed
   full-viewport, its content just stacked at the origin.
   Fixed here rather than in the app section: that file is vendored EG output and
   would lose the edit on any app update. Two class selectors outrank Bootstrap's
   one, so this wins the !important tie. Q&A's modal is unaffected — it uses
   .yotpo-qna-modal-backdrop, a name Bootstrap can't collide with. */
.modal-backdrop.yotpo-review-form-modal {
  display: flex !important;
  align-items: center;
  justify-content: center;
}

/* inline links pick up the signal violet */
.yotpo-widget-instance .yotpo-read-more { color: var(--brand) !important; }

/* Yotpo derives a 5%-alpha wash from the accent colour set in its admin, so the
   review cards and the header block came out rgba(77,30,247,.05) — a faint
   lavender on every card. That is violet decorating rather than signalling, and
   the neutral #F4F3F3 plate it was swapped for still read as a grey slab across
   the whole section, so the fill comes off entirely: the widget sits on the
   white page and the card shape is carried by a hairline instead.
   Scoped under .yotpo-widget-instance because ".card-container" is a generic
   name; !important because the rule ships from Yotpo's cross-origin stylesheet.
   The full-violet ask-a-question button and the active pagination border are
   left alone — those are real signals. */
.yotpo-widget-instance .yotpo-header-container,
.yotpo-widget-instance .card-container {
  background-color: transparent !important;
}
.yotpo-widget-instance .card-container {
  border: 1px solid var(--hairline) !important;
}

/* ...and the same frames take the bevel corner language the rest of the page
   uses. Yotpo ships square headers and a flat 3px card radius, which read as
   default-Shopify next to the beveled stage and spec cards. */
.yotpo-widget-instance .yotpo-header-container {
  border-radius: 3px 3px 24px 3px !important;
  corner-shape: round round bevel round;
}
.yotpo-widget-instance .card-container {
  border-radius: 3px 3px 18px 3px !important;
  corner-shape: round round bevel round;
}

/* ---- Mobile: reviews drop the card and take the full width ------------------
   The hairline + bevel card above is a desktop decision — it carries the card
   shape on the white page where there is room for the frame. On a 375px phone
   that frame costs ~34px of width to its own border and inset, and stacks a
   dozen bevelled outlines down the page: the boxed-list look this store is
   explicitly not (it separates by hairline and tonal step, never by a box drawn
   around every item). So the frame comes off, the copy and the review photo run
   the full 16px container, and a single hairline BETWEEN reviews — the page's
   own row device, shared with the spec table and FAQ — keeps them discrete.

   Must sit AFTER the desktop .card-container rules: this ties them on the two
   real classes and only wins on source order, and the extra .yotpo-review-card
   makes it win outright. The between-card rule (Yotpo's hydrated markup is
   .yotpo-review-card > .card-container; the SSR .yotpo-review is replaced on
   load) never reaches the Q&A widget, which has no .yotpo-review-card. */
@media (max-width: 991.98px) {
  .yotpo-widget-instance .yotpo-review-card > .card-container {
    border: 0 !important;
    border-radius: 0 !important;
    background: transparent !important;
    box-shadow: none !important;
    padding-inline: 0 !important;
    /* the separator, plus the vertical rhythm the box padding used to hold */
    padding-block: 24px !important;
    border-top: 1px solid var(--hairline) !important;
  }
  /* first review needs no rule above it — the widget's own heading closes the
     block already */
  .yotpo-reviews-cart-list > .yotpo-review-card:first-child > .card-container {
    border-top: 0 !important;
    padding-top: 0 !important;
  }
}

/* Ratings summary. Yotpo lays the header block out as a plate: 20/15 inner
   padding, row-reverse, justify space-around — so the 4.5 floated at the far
   right and the distribution bars sat mid-left, with ~70px of dead height under
   them. That composition only held together because a fill drew the panel
   around it; on white the two halves read as stranded islands. Re-set to span
   the container instead: score flush to the left content edge, distribution
   flush to the right one, so the block is balanced by its own two ends rather
   than floating mid-row. The 56px gap is a floor, not the spacing — it only
   bites if the container ever gets narrow enough for the two to meet.
   Left in Yotpo's own stacking below md so the group still wraps on phones.
   !important throughout: Yotpo ships these layout properties !important from
   its own stylesheet — align-items and gap land without it, the rest don't. */
.yotpo-widget-instance .yotpo-layout-header-wrapper { padding: 0 !important; }
.yotpo-widget-instance .yotpo-star-distribution {
  margin: 0 !important;
  padding-inline: 0 !important;
}
.yotpo-widget-instance .yotpo-summary-section { margin-bottom: 0 !important; }

@media (min-width: 768px) {
  .yotpo-widget-instance .yotpo-bottom-line {
    flex-direction: row !important;
    justify-content: space-between !important;
    align-items: center;
    gap: 56px;   /* on-scale; a minimum, not the resting gap */
  }
  /* the section ships a fixed 200px centred column; let it hug its content */
  .yotpo-widget-instance .yotpo-summary-section {
    width: auto !important;
    align-items: flex-start !important;
  }
}

/* ---- Phones: the widget stops centring itself --------------------------------
   Below 768 the block above stops applying and Yotpo's own stacking takes over,
   which centres the score, the star row and the "Write a review" button in the
   column. Every other thing on a mobile page — the "Reviews" heading directly
   above it, the search field and sort row directly below it — is flush to the
   16px page edge, so the summary read as a floating island between two
   left-aligned neighbours. Same rules the desktop block uses, applied to the
   stacked direction. !important throughout for the same reason it is used there:
   Yotpo ships these layout properties !important from its own stylesheet.
   -------------------------------------------------------------------------- */
@media (max-width: 767px) {
  .yotpo-widget-instance .yotpo-layout-header-wrapper {
    align-items: flex-start !important;
    margin-bottom: 24px !important;
  }
  /* the fixed 200px centred column again — let it hug its content and sit left */
  .yotpo-widget-instance .yotpo-summary-section {
    width: auto !important;
    align-items: flex-start !important;
  }
  .yotpo-widget-instance .yotpo-bottom-line {
    justify-content: flex-start !important;
    margin-bottom: 24px !important;
  }
  /* Yotpo's own 6px indent on this one headline, which put it off the axis its
     own gallery images sit on */
  .yotpo-widget-instance .yotpo-media-gallery-headline { margin-left: 0 !important; }

  /* The Q&A widget centres its "Ask a question" button the same way, but by a
     different mechanism: its wrapper is a COLUMN flex and the button carries
     align-self:center, so the centring is on the cross axis and
     justify-content never touches it. Both axes are set here so it lands
     whichever direction Yotpo ships, and the button's own align-self has to be
     overridden directly — setting align-items on the parent alone loses to it.
     40px of margin above it goes too; the heading is 16px away, and the button
     was sitting nearly 100px below the words it belongs to. */
  .yotpo-questions-and-answers .yotpo-header-container { margin-top: 16px !important; }
  .yotpo-questions-and-answers .yotpo-ask-question-btn-wrapper {
    justify-content: flex-start !important;
    align-items: flex-start !important;
    text-align: left !important;
  }
  .yotpo-questions-and-answers .yotpo-question-btn { align-self: flex-start !important; }
}

/* Within the summary, the stars sat 9px above the numeral's optical centre:
   Yotpo pads their panel 12px from the top of the score's line box, but digits
   occupy only the ascender half of that box (48px type, 34px cap, ~1px below
   the baseline — centre lands 27px down, not 21px). 12 + 9 = 21px puts the star
   row on the digits; "Based on N reviews" keeps hanging below it. */
.yotpo-widget-instance .yotpo-bottom-line-right-panel { padding-top: 21px; }

/* the Q&A widget insets its own layout by ~5%, so its content sat 46px inside
   the reviews block and the headings — pull it back to the content edge */
.yotpo-questions-and-answers .yotpo-main-layout {
  margin-inline: 0 !important;
  width: 100% !important;
  max-width: none !important;
}

/* ---- Specifications (store.specs metafield) ---- */
.bss-specs-band .bss-h2 { margin-bottom: 32px; }
.bss-spectable { border-top: 1px solid var(--hairline); }
.bss-specrow {
  display: grid;
  grid-template-columns: 56px minmax(0, 1fr) minmax(0, 1.35fr);
  gap: 24px;
  padding: 40px 0;   /* symmetric between the hairlines; was 40/48 */
  border-bottom: 1px solid var(--hairline);
}

/* One 18/24 line box for every cell in the row — this is what makes the table
   line up. The columns previously ran three different metrics (15px mono,
   17px/1.4, 17px/1.55), so each column carried a different half-leading and
   the first baselines landed at three different heights across the row.
   Identical size + identical line-height = identical half-leading = one shared
   baseline, and the 24px leading puts the whole table on a 24px rhythm.
   Note: the row numbers are Neue Haas here, not Fragment Mono — Max's call,
   an intentional exception to the Caliper Rule for this table. */
.bss-specrow-no,
.bss-specrow-title,
.bss-specitem dt,
.bss-specitem dd {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 18px;
  line-height: 24px;
}
.bss-specrow-no { color: #0a0a0c; }
/* explicit: Dawn's heading rule lands pure #000 here, off by a hair from the
   #0a0a0c the numbers and values carry */
.bss-specrow-title { margin: 0; color: #0a0a0c; }
/* ---- Collapsible groups ------------------------------------------------------
   Native <details>, no JS. Nothing here changes a column, a size or a step: the
   summary is a subgrid spanning the number + title cells, so 01 still starts at
   the row's left edge and the title still starts at the same x it always did.
   The chevron sits at the right edge of the TITLE column — inside the trailing
   space that column already had, so it costs no width and pushes nothing. */
.bss-specrow-head {
  grid-column: 1 / 3;
  display: grid;
  grid-template-columns: subgrid;
  position: relative;
  cursor: pointer;
  list-style: none;   /* kills the default disclosure triangle (Firefox) */
}
.bss-specrow-head::-webkit-details-marker { display: none; }
/* explicit rather than relying on auto-placement: in browsers that DO support
   ::details-content the dl is no longer a grid item (the pseudo is), and in
   browsers that don't, the dl is — so both are pinned to the same column */
.bss-speclist,
.bss-specrow::details-content { grid-column: 3; }
/* 24px box on a 24px line: the mark rides the first baseline row, level with
   the number and the title rather than floating between them */
.bss-specrow-mark {
  position: absolute; right: 0; top: 0;
  width: 24px; height: 24px;
  display: grid; place-items: center;
  color: #70777A;
  transition: transform 320ms var(--ease-signature);
}
/* down = "expand me", up = "collapse me"; open is the default state */
.bss-specrow[open] .bss-specrow-mark { transform: rotate(180deg); }
.bss-specrow-head:hover .bss-specrow-mark { color: #0a0a0c; }

/* Height animation needs ::details-content, which Safari and Firefox don't have
   yet; there it degrades to an instant open/close, which is the same behaviour
   a plain <details> has always had. interpolate-size is scoped to the table
   rather than :root so nothing else on the page gains auto-height transitions. */
@supports selector(::details-content) {
  .bss-spectable { interpolate-size: allow-keywords; }
  .bss-specrow::details-content {
    block-size: 0;
    overflow: hidden;
    opacity: 0;
    transition: block-size 380ms var(--ease-signature),
                opacity 240ms var(--ease-signature),
                content-visibility 380ms allow-discrete;
  }
  .bss-specrow[open]::details-content { block-size: auto; opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .bss-specrow-mark,
  .bss-specrow::details-content { transition: none; }
}

/* no gap: the 24px leading is the rhythm, a gap would push it off the grid */
.bss-speclist { margin: 0; display: flex; flex-direction: column; }
.bss-specitem { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.7fr); gap: 24px; }
.bss-specitem dt { color: #70777A; }   /* the one secondary grey; was #9BA1A4 */
.bss-specitem dd { margin: 0; color: #0a0a0c; }
@media (max-width: 991.98px) {
  /* 24 / 32, not the old 20 / 28-32 — both were off the 8px ramp */
  .bss-specrow { grid-template-columns: 40px minmax(0, 1fr); row-gap: 24px; padding: 32px 0; }
  .bss-speclist,
  .bss-specrow::details-content { grid-column: 1 / -1; }

  /* ---- Stacked, not two columns --------------------------------------------
     Side by side in ~343px the term got ~135px and the value ~190px, and at
     18/24 almost every value wrapped — "RTX 2070 or AMD RX 5700 XT (DisplayPort
     1.4 and DSC required)" ran to three lines against a one-line term. The pairs
     stopped reading as rows: single-line items sat tight while wrapped ones
     sprawled, so the list had no rhythm to scan down, and the value column was
     doing all that wrapping while 40% of the width sat empty beside the terms.

     Stacked, the value gets the full measure and wraps maybe once. What
     separates the pair is register rather than position — and the term takes the
     mono label voice the spec CARDS higher up this page already use, which is
     also what DESIGN.md specifies for spec names. So the list reads label,
     value, label, value, with the grey mono line always marking where a new
     spec starts.
     -------------------------------------------------------------------------- */
  /* Ruled rows, not a spaced list. Stacking alone stopped the wrapping but left
     ten label/value pairs floating in a column, held apart by nothing but air —
     which reads as a generic mobile spec list rather than as this page. The
     hairline is the page's own row device: CLAUDE.md counts fourteen of them
     running through this table and the FAQ as repeating row structure, and using
     it here makes the list a machined table, delimited and countable, in the
     same language as everything around it.

     Each item is then a fixed unit — rule, 12, label, value, 12 — so the rhythm
     is identical whether the value takes one line or two. That constancy is what
     the 24px-of-air version could not give: there, a wrapped value silently made
     its row half again as tall as its neighbours. */
  .bss-speclist { gap: 0; }
  .bss-specitem {
    display: block;
    border-top: 1px solid var(--hairline);
    padding: 12px 0;
  }
  /* The term keeps the table's Neue Haas 18/24 in the secondary grey — no size
     step, no mono, no caps. It was briefly set as a 12px uppercase mono label,
     which DESIGN.md does sanction for spec names, but it reads as an import from
     the chrome: this store spends mono on the nav, the ticker and prices-in-
     chrome, and a spec table is reading matter. Grey against black is the whole
     distinction, exactly as the desktop table and the live page make it — so
     mobile and desktop now differ only in ARRANGEMENT (stacked vs columns),
     never in voice. */
  .bss-specitem dd {
    font-size: 18px; line-height: 24px;
    /* the values are overwhelmingly measurements; tabular figures keep the
       digits on a common width down the column */
    font-variant-numeric: tabular-nums;
  }

  /* Group titles stay on the table's shared 18/24, same as the number, the terms
     and the values. They were briefly stepped up to 22/30 to separate "section"
     from "reading" — but that step does not exist in this table on desktop or on
     the live page, and the one 18/24 line box across every cell is the whole
     reason the thing aligns (see the metric note further up). The section is
     already marked by its number, its rule and the space around it; it does not
     need a size to say so as well. */
}

/* ---- Scroll reveal ----------------------------------------------------------
   The below-fold bands fade up as they enter. Motion serves the reveal, so it
   stays to one gesture: 16px of travel (on-scale) and the signature ease, no
   scale, no blur, nothing per-word.

   Two attributes, both set in bs-product-story.liquid:
     [data-bss-reveal]        the element itself fades up
     [data-bss-reveal-group]  its direct children fade up, staggered

   Everything is gated on .bss-js, which the section's first inline script adds
   during parse (before the bands are parsed, so there is no flash). With JS
   off, or under reduced motion, the class is never set / is removed and the
   content is simply visible — the hidden state can never strand content.
   -------------------------------------------------------------------------- */
.bss-js [data-bss-reveal],
.bss-js [data-bss-reveal-group] > * {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity var(--dur-slow) var(--ease-signature),
              transform 900ms var(--ease-signature);
}
.bss-js [data-bss-reveal].is-in,
.bss-js [data-bss-reveal-group].is-in > * {
  opacity: 1;
  transform: none;
}
/* grouped rows (USP columns, feature cards, spec stats) walk in behind each
   other; the group tops out at 4 children, which every row here respects */
.bss-js [data-bss-reveal-group].is-in > :nth-child(2) { transition-delay: 80ms; }
.bss-js [data-bss-reveal-group].is-in > :nth-child(3) { transition-delay: 160ms; }
.bss-js [data-bss-reveal-group].is-in > :nth-child(4) { transition-delay: 240ms; }

@media (prefers-reduced-motion: reduce) {
  .bss-js [data-bss-reveal],
  .bss-js [data-bss-reveal-group] > * {
    opacity: 1;
    transform: none;
    transition: none;
  }
}


/* =============================================================================
   Discord CTA (bs-discord-cta) — the last band on the product page.

   A filled violet plate was built here first and pulled. At this size the
   colour became the loudest object on the page, louder than the "Order now"
   button that actually matters, which breaks The Single Signal Rule at page
   level; and a coloured slab running copy-left / button-right is the
   storefront banner PRODUCT.md lists as an anti-reference. It also cropped the
   prefooter's circuit line-art, the only art this end of the page has.

   What the page actually uses below the fold is a hairline, air, and one
   violet object you can click: the USP row and the spec table are both
   hairline-ruled, and every violet mark down here is a control. This band is
   built from those parts. Scale carries it, not colour.
   ============================================================================= */
.bs-dcta {
  /* A voice change, like .bsa and .bsf — the reviews and the customer Q&A sit
     directly above this, so the invitation opens on the wider step rather than
     reading as one more row of the same widget.
     No bottom padding — the fine print below opens on its own spacer-120. */
  padding-top: 160px;
  font-family: var(--font-display); font-weight: 400;
}
.bs-dcta-inner {
  max-width: var(--desktopMaxWidth);
  margin-inline: auto;
  padding: 0 16px;
  box-sizing: border-box;
}
/* 1.4fr / 1fr is .bss-split-row's ratio, the same split the optics band uses
   for copy-beside-figure — so the button lands on a column edge the page has
   already established instead of inventing one. */
/* Bottom-aligned, not centred: centred put the button in the dead zone between
   the headline and the body copy, attached to neither. On the baseline of the
   last body line the band gets one firm bottom edge across its full width. */
.bs-dcta-row {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
  gap: 40px; align-items: end;
  border-top: 1px solid var(--hairline);
  padding-top: 56px;
}
/* On a product with neither reviews nor questions both bands between the story
   and this one collapse, and the rule above lands 120px under the story's own
   closing hairline — two rules with empty space between them. The story's rule
   already draws that edge, so this band drops its own and the 56px that existed
   to clear it, leaving the plain 120px band gap. Set by bs-yotpo-empty.js. */
.bss-yt-none .bs-dcta-row { border-top: 0; padding-top: 0; }
.bs-dcta-title {
  margin: 0 0 16px;
  /* Dawn wires h1-h6 colour straight to --color-foreground, so the heading
     states its own rather than inheriting one */
  color: #0a0a0c;
  /* Large token (64/72 desktop, 36/42 mobile): one step above the 48px band
     headlines above it, because this is the page's closing line */
  font-size: clamp(36px, 4.4vw, 64px); font-weight: 400;
  line-height: clamp(42px, 4.9vw, 72px); letter-spacing: -.02em;
  text-wrap: balance;
}
.bs-dcta-body {
  margin: 0;
  font-size: 18px; line-height: 24px;    /* Body Small 18/24 */
  color: #70777A; max-width: 52ch;
  text-wrap: pretty;
}
/* The header's "Order now" object, verbatim: violet fill, mono caps, the 2/10
   bevel, and the same deepening hover. A black fill was tried first, to keep
   violet exclusive to the sticky pill's "Order now" — Max's call is violet, and
   it holds: this is the only control in the band, so within the view it is
   still one signal on one action. */
.bs-dcta-btn,
.bs-dcta-btn:link,
.bs-dcta-btn:visited {
  justify-self: end;
  display: inline-flex; align-items: center; gap: 10px;
  height: 48px; padding: 0 24px;
  background: var(--brand);
  color: #fff;
  border: 0;
  border-radius: 2px 2px 10px 2px;
  corner-shape: round round bevel round;
  font-family: var(--font-mono);
  font-size: 14px; letter-spacing: .08em; text-transform: uppercase;
  text-decoration: none; white-space: nowrap;
  transition: background-color 280ms ease;
}
.bs-dcta-btn:hover { background: var(--brand-deep); color: #fff; }
/* the global focus ring is violet, which vanishes on a violet fill */
.bs-dcta-btn:focus-visible { outline: 2px solid #0a0a0c; outline-offset: 3px; }
.bs-dcta-btn svg { flex: 0 0 auto; }

/* The :link/:visited pair is repeated in every override below on purpose: the
   base rule carries it (to beat the theme's own anchor styling), which makes
   it a two-class selector, and a plain .bs-dcta-btn rule silently loses to it. */
@media (max-width: 991.98px) {
  .bs-dcta { padding-top: 120px; }
  .bs-dcta-row {
    grid-template-columns: 1fr;
    gap: 32px;
    padding-top: 40px;
    align-items: start;
  }
  .bs-dcta-btn,
  .bs-dcta-btn:link,
  .bs-dcta-btn:visited { justify-self: start; }
}
/* Full width only once the band is phone-narrow — on a tablet a button spanning
   700px reads as a bar, not a button. */
@media (max-width: 640.98px) {
  .bs-dcta-btn,
  .bs-dcta-btn:link,
  .bs-dcta-btn:visited {
    justify-self: stretch;
    width: 100%; justify-content: center; padding: 0 16px;
    /* the URL label is 26 characters: at 14px mono the glyphs alone measure
       ~240px, which overflows the 295px button a 375px phone allows (295 vs
       240 + 18 icon + 10 gap + 32 padding = 300). Tracking comes off first,
       then the type size. */
    font-size: 13px;
  }
  .bs-dcta-btn span { letter-spacing: .04em; }
}
@media (max-width: 360px) {
  .bs-dcta-btn,
  .bs-dcta-btn:link,
  .bs-dcta-btn:visited { font-size: 12px; }
  .bs-dcta-btn span { letter-spacing: .02em; }
}


/* =============================================================================
   In action (bs-in-action) — owner photography as a horizontal rail, above the
   reviews widget.

   The row starts on the container's left edge, so the first card lines up with
   every headline on the page, and runs off the right edge: the crop IS the
   affordance that says it continues. Contained on both sides would have needed
   a scrollbar or arrows to say the same thing twice.

   Card widths are not authored. Every card is the same height and carries an
   aspect-ratio, so a portrait shot and a landscape shot sit side by side and
   size themselves. The ratio is set before the pixels arrive, so the row never
   reflows as images load in.
   ============================================================================= */
.bsa {
  /* ---- where the page changes who is talking ------------------------------
     Every band below the configurator opened on the same 120, which made the
     whole lower page one metronome: four bands of hardware argument, a rail of
     owner photos, thirteen questions and a Discord invitation, all arriving at
     identical intervals. The 120 is right INSIDE the story — those bands are one
     continuous argument and belong at one pace — but this is where the voice
     changes from us describing the hardware to owners showing it. So the three
     hand-offs (here, the FAQ, the Discord close) open at the next step up
     instead. Nothing tightens; the page just stops being evenly spaced.
     160 desktop maps to 120 on the mobile ramp (the Compression Rule). */
  padding-top: 160px;
  font-family: var(--font-display); font-weight: 400;
  --bsa-h: clamp(300px, 32vw, 440px);
  /* the content edge of .bss-container / .bs-dcta-inner, resolved against the
     full-width rail: outer gutter + the 16px inner gutter. Below the container
     width the first term goes negative and the flat 16px takes over. */
  --bsa-edge: max(16px, calc((100% - var(--desktopMaxWidth)) / 2 + 16px));
}
.bsa-inner {
  max-width: var(--desktopMaxWidth);
  margin-inline: auto;
  padding: 0 16px;
  box-sizing: border-box;
}
.bsa-title {
  margin: 0 0 16px;
  color: #0a0a0c;                /* Dawn wires h2 colour to --color-foreground */
  /* the .bss-h2 ramp — a peer of "Advanced optics" and "Specifications" */
  font-size: clamp(32px, 3.4vw, 48px); font-weight: 400;
  line-height: clamp(40px, 3.9vw, 56px); letter-spacing: -.01em;
}
.bsa-lede {
  margin: 0;
  font-size: 18px; line-height: 24px;   /* Body Small 18/24 */
  color: #70777A; max-width: 62ch;
}

.bsa-rail {
  display: flex; gap: 16px;             /* the .bss-media-grid gutter */
  margin: 40px 0 0; padding: 0;
  padding-inline: var(--bsa-edge);
  list-style: none;
  overflow-x: auto; overflow-y: hidden;
  overscroll-behavior-x: contain;       /* don't hand a horizontal fling to the page */
  scroll-snap-type: x proximity;
  /* proximity, not mandatory: the arrows scroll by a fraction of the viewport
     rather than a card (cards are different widths), and mandatory fights that
     mid-flight.
     scroll-padding is not optional here: the snapport is the container's
     PADDING box, so "snap to start" parks the first card against the viewport
     edge and scrolls the left inset away on load. This moves the snapport in to
     the content edge, which is what keeps card one under the headline. */
  scroll-padding-inline: var(--bsa-edge);
  scroll-behavior: smooth;
  scrollbar-width: none;                /* the arrows are the control */
}
.bsa-rail::-webkit-scrollbar { display: none; }
.bsa-rail:focus-visible { outline: 2px solid var(--brand); outline-offset: 4px; }

.bsa-card {
  position: relative;
  flex: 0 0 auto;
  height: var(--bsa-h);
  width: auto;                          /* aspect-ratio does the sizing */
  scroll-snap-align: start;
  overflow: hidden;
  background: var(--off-white);         /* the frame before the pixels arrive */
  /* the story's media bevel, verbatim */
  border-radius: 3px 3px 18px 3px;
  corner-shape: round round bevel round;
}
.bsa-img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* Owner credit — the .bss-chip mark, moved to the bottom-left: the bevel owns
   the bottom-right corner, and top-right is where the story's "Beyond 2e" tag
   lives, so this stays distinct from it. */
.bsa-credit {
  position: absolute; left: 12px; bottom: 12px;
  max-width: calc(100% - 24px);
  padding: 5px 10px;
  background: #000; color: #fff;
  border: 1px solid rgba(255, 255, 255, .22);
  font-family: var(--font-mono);
  font-size: 11px; line-height: 16px; letter-spacing: .06em; text-transform: uppercase;
  border-radius: 2px 2px 8px 2px;
  corner-shape: round round bevel round;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* ---- video cards ---- */
.bsa-play {
  display: block; width: 100%; height: 100%; padding: 0;
  border: 0; background: none; cursor: pointer;
  position: relative;
}
.bsa-play:focus-visible { outline: 2px solid var(--brand); outline-offset: -4px; }
/* the poster is a frame we chose, so it can take a wash: the mark and the
   creator line have to stay legible over whatever it turns out to be */
.bsa-card--video .bsa-img { filter: brightness(.86); transition: filter var(--dur) ease; }
.bsa-play:hover .bsa-img { filter: brightness(.78); }
.bsa-play-mark {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  display: flex; align-items: center; justify-content: center;
  width: 56px; height: 56px;
  padding-left: 2px;                    /* the glyph's own optical centre */
  background: rgba(10, 10, 12, .72); color: #fff;
  border: 1px solid rgba(255, 255, 255, .3);
  border-radius: 3px 3px 16px 3px;
  corner-shape: round round bevel round;
  transition: background-color var(--dur) ease;
}
.bsa-play:hover .bsa-play-mark { background: var(--brand); border-color: var(--brand); }
.bsa-meta {
  position: absolute; left: 16px; right: 16px; bottom: 14px;
  display: flex; flex-direction: column; gap: 4px;
  text-align: left; color: #fff;
}
.bsa-meta-name {
  font-family: var(--font-mono);
  font-size: 12px; line-height: 16px; letter-spacing: .06em; text-transform: uppercase;
}
.bsa-meta-title {
  font-size: 15px; line-height: 20px; color: rgba(255, 255, 255, .82);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.bsa-iframe { display: block; width: 100%; height: 100%; border: 0; }

/* ---- arrows ---- */
/* Under the rail on the container's right edge, not floated over the artwork:
   the photographs are the point, and an overlaid control sits on someone's
   living room. Same object as .bss-slider-arrow otherwise. */
.bsa-navrow { margin-top: 24px; }
.bsa-nav { display: flex; justify-content: flex-end; gap: 8px; }
.bsa-arrow {
  display: flex; align-items: center; justify-content: center;
  width: 44px; height: 44px;
  background: #fff; color: #0a0a0c;
  border: 1px solid var(--hairline);
  border-radius: 2px 2px 12px 2px;
  corner-shape: round round bevel round;
  cursor: pointer;
  transition: background-color var(--dur) ease, color var(--dur) ease, border-color var(--dur) ease, opacity var(--dur) ease;
}
.bsa-arrow:hover:not(:disabled) { background: #0a0a0c; color: #fff; border-color: #0a0a0c; }
.bsa-arrow:disabled { opacity: .3; cursor: default; }
.bsa-arrow:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; }

@media (max-width: 991.98px) {
  .bsa { padding-top: 120px; --bsa-h: clamp(260px, 46vw, 340px); }
  .bsa-rail { margin-top: 32px; gap: 12px; }
  .bsa-play-mark { width: 48px; height: 48px; }
}
/* The rail's whole argument is that the crop says "it continues" — and at phone
   width it stopped saying it. Cards are sized by height, so a 260px-tall 4:3
   card came out 347px wide against 343px of content: the first card filled the
   row exactly and the second sat entirely off screen, leaving a rail that looked
   like a single photo with two arrows under it. Dropping the height narrows
   every card enough that the next one always breaks the edge. */
@media (max-width: 640.98px) {
  .bsa { --bsa-h: clamp(210px, 56vw, 260px); }
}
@media (prefers-reduced-motion: reduce) {
  .bsa-rail { scroll-behavior: auto; }
  .bsa-card--video .bsa-img,
  .bsa-play-mark { transition: none; }
}

/* ---- Bevel fallback — no corner-shape support (Safari/iOS, Firefox): the
   bevel radius would render as a fat curve. Fill surfaces get the real cut
   via clip-path; convention, polygon template, and the outline-ring
   exception in bs-chrome.css ("BEVEL FALLBACK"). Kept on the quiet corner
   here: the hairline-ringed cards (option/link cards, check,
   halo note, thumbs, arrows, badge chips on light) and Yotpo's bordered
   third-party markup. ---- */
@supports not (corner-shape: round round bevel round) {
  .bsp-stage {
    border-radius: 3px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 32px), calc(100% - 32px) 100%, -80px calc(100% + 80px));
  }
  .bsp-stage--vrchat .bsp-addon-preview {
    border-radius: 2px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 10px), calc(100% - 10px) 100%, -80px calc(100% + 80px));
  }
  .bsp-opt-thumb {
    border-radius: 2px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 8px), calc(100% - 8px) 100%, -80px calc(100% + 80px));
  }
  /* clip-path promotes the thumb into the positioned paint layer, and the
     check indicator sits EARLIER in the DOM — without this it slides behind
     the thumb it is meant to sit on */
  .bsp-opt-check { z-index: 1; }
  .bsp-opt-chip,
  .bsp-swatch-chip {
    border-radius: 2px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 9px), calc(100% - 9px) 100%, -80px calc(100% + 80px));
  }
  .bsp-note,
  .bss-media,
  .bss-note,
  .bsa-card {
    border-radius: 3px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 18px), calc(100% - 18px) 100%, -80px calc(100% + 80px));
  }
  .bsp-order {
    border-radius: 2px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 14px), calc(100% - 14px) 100%, -80px calc(100% + 80px));
  }
  .bss-slider-arrow {
    border-radius: 2px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 12px), calc(100% - 12px) 100%, -80px calc(100% + 80px));
  }
  .bss-slider-play {
    border-radius: 2px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 9px), calc(100% - 9px) 100%, -80px calc(100% + 80px));
  }
  /* dark chips: their faint white keylines hide the notch, so they keep the cut */
  .bss-chip {
    border-radius: 2px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 10px), calc(100% - 10px) 100%, -80px calc(100% + 80px));
  }
  .bsa-credit {
    border-radius: 2px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 8px), calc(100% - 8px) 100%, -80px calc(100% + 80px));
  }
  .bsa-play-mark {
    border-radius: 3px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 16px), calc(100% - 16px) 100%, -80px calc(100% + 80px));
  }
  .bs-dcta-btn, .bs-dcta-btn:link, .bs-dcta-btn:visited {
    border-radius: 2px;
    clip-path: polygon(-80px -80px, calc(100% + 80px) -80px, 100% calc(100% - 10px), calc(100% - 10px) 100%, -80px calc(100% + 80px));
  }
  /* outline-ring surfaces: quiet corner, no cut (.bsp-swatch label is a plain
     3px plate now; its cut is on .bsp-swatch-chip above) */
  .bsp-opt label,
  .bsp-opt--link { border-radius: 3px; }
  .bsp-opt-check,
  .bsp-halo-note,
  .bss-thumb,
  .bsa-arrow { border-radius: 2px; }
  /* Yotpo overrides carry !important upstream, so the fallback must too */
  .yotpo-widget-instance .yotpo-header-container,
  .yotpo-widget-instance .card-container { border-radius: 3px !important; }
  .yotpo-widget-instance .yotpo-question-btn,
  .yotpo-widget-instance .yotpo-new-review-btn,
  .yotpo-widget-instance [class*="write-review-btn"]:not([class*="wrapper"]),
  .yotpo-widget-instance [class*="ask-question-btn"]:not([class*="wrapper"]),
  .yotpo-widget-instance .yotpo-vertical-pagination-item { border-radius: 2px !important; }
}
