/* ==========================================================================
   Product Detail Page — above-the-fold shell (layout, gallery, buy box).
   ==========================================================================

   Ported from the approved ECOM-first mockup:
   https://cortex.ernieball.com/pin/moderith/eb-pdp-ecom-mockup

   SHARED, NOT PER-BRAND. This file lives in public/assets/shared/css/pages/
   because nothing in it is brand-specific any more — every colour comes from a
   --pdp-* token, and those delegate to the per-brand --brand-* tokens. EB gets
   Slinky pink / EB Red, MM gets gold with dark ink on fill, from one stylesheet.
   site_asset() resolves brand-first then falls back to shared, so the path
   string in the view is unchanged and MM stops 404ing on it.

   LOAD ORDER: pdp-tokens.css MUST come first. Every value below resolves
   through it; on its own this file paints unstyled greys.

   LIGHT MODE ONLY. No prefers-color-scheme block belongs in this file — dark
   mode is a deliberate post-launch item for the whole PDP at once.
   ========================================================================== */

/* --- Page frame ---------------------------------------------------------- */

/*
 * `.container` is not defined anywhere in the site CSS, so the PDP had no
 * max-width and no gutter at all. `.pdp-page` supplies both without touching
 * the global class (other catalog surfaces still key off `.container`).
 */
.pdp-page {
    max-width: var(--pdp-maxw);
    margin: 0 auto;
    padding: 0 18px;
}

/* Room for the docked sticky bar (Jesse, 2026-09-09): once it stops above the
   footer it would sit over the last section, so pages that build the bar
   reserve its height beneath the content. product-detail.js writes the
   measured height to --pdp-stickybar-h; the fallback is the desktop bar. */
.pdp-page--stickybar {
    padding-bottom: var(--pdp-stickybar-h, 72px);
}

/* Buttons/inputs inside the PDP inherit the page font rather than the UA's. */
.pdp-page button,
.pdp-page input {
    font-family: inherit;
}

/*
 * Referenced by the qty label and the retailer <select> label but never
 * defined site-wide, so those labels were rendering as visible orphan text.
 */
.pdp-page .visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* --- Breadcrumbs --------------------------------------------------------- */

.pdp-breadcrumbs {
    padding: 18px 0 0;
}

.pdp-breadcrumbs ol {
    list-style: none;
    padding: 0;
    margin: 0 0 16px;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 7px;
    font-size: 12.5px;
    color: var(--pdp-ink-3);
}

.pdp-breadcrumbs li {
    display: flex;
    align-items: center;
    gap: 7px;
}

.pdp-breadcrumbs li:not(:last-child)::after {
    content: '/';
    opacity: 0.5;
}

.pdp-breadcrumbs a {
    color: inherit;
    text-decoration: none;
}

.pdp-breadcrumbs a:hover {
    color: var(--pdp-accent-ink);
}

.pdp-breadcrumbs [aria-current="page"] {
    color: var(--pdp-ink-2);
    font-weight: 600;
}

/* --- Two-column layout --------------------------------------------------- */

.pdp {
    padding-bottom: var(--pdp-section-y);
    color: var(--pdp-ink);
}

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

@media (min-width: 900px) {
    .pdp__layout {
        /*
         * THE BUY COLUMN IS A FIXED WIDTH. THE GALLERY TAKES ALL THE FLEX.
         *
         * It used to be `minmax(340px, 0.85fr)`, so it grew from 346px to 524px
         * across the desktop range and the variant rail was a different size on
         * every screen — 80px tiles at 900px, 125px at full width. The narrow
         * end clipped labels, and nothing about a buy column wants to be elastic
         * anyway: it holds a price, a control and a button, none of which read
         * better for being wider.
         *
         * Fixed at 440px, the four-column rail is 104px per tile at every
         * desktop width. Predictable, and wide enough for the 26-character
         * colorways on the accessory pages that were clipping.
         *
         * The gallery absorbs the remainder — 374px at 900px up to 794px at full
         * width — which is the right way round: product art scales gracefully,
         * a grid of controls does not.
         *
         * Tied to the rail, so re-derive if the column count changes:
         * tile = (width - (columns - 1) * 8px gap) / columns.
         *
         * TWO STEPS, not one. Between 900 and 1100 a 440px buy column left the
         * gallery at 374px and the product art visibly suffered. So that range
         * takes a 360px column with a THREE-column rail, which is better on both
         * counts at once: the gallery gains 80px and the tile grows to 115px,
         * because three tiles in a narrow box beat four in a slightly wider one.
         * The rail step is in the rules beside .pdp__variant-list and the two
         * must move together.
         */
        grid-template-columns: minmax(0, 1fr) 360px;
        gap: 38px;
        align-items: start;
    }
}

@media (min-width: 1100px) {
    .pdp__layout {
        grid-template-columns: minmax(0, 1fr) 440px;
    }
}

/* --- Gallery ------------------------------------------------------------- */

.pdp__gallery {
    display: grid;
    gap: 12px;
    min-width: 0;
    align-content: start;
}

/*
 * STICKY GALLERY.
 *
 * position: sticky on the gallery INSIDE the two-column grid, so it pins itself
 * near the top of the viewport and then stops on its own when .pdp__layout ends
 * — which is exactly where the below-fold sections (pack contents first) begin.
 * No scroll listener, no measured offsets, and nothing to unbind.
 *
 * `align-self: start` is load-bearing: a grid item stretched to the row height
 * has nowhere to travel, so a stretched item never sticks at all.
 *
 * Only above the 900px breakpoint. Below it the columns stack, the gallery is
 * the full width of the page, and pinning it would hold the photo over the buy
 * box for the entire scroll.
 *
 * Nothing here traps focus: the gallery keeps its normal DOM position and tab
 * order, and sticky changes paint position only. No transition is declared, so
 * there is nothing for prefers-reduced-motion to opt out of — if one is ever
 * added here it must be wrapped in a reduced-motion guard.
 */
@media (min-width: 900px) {
    .pdp__gallery {
        position: sticky;
        /*
         * The offset must clear the sticky header, not just the viewport edge.
         * `.site-header` is itself `position: sticky; top: 0` at 77px tall
         * (z-index 1030, so it paints over the gallery) — a bare `top: 24px`
         * pinned the gallery 24px from the viewport top and put its upper 53px
         * behind the header.
         *
         * The var is a seam: nothing defines --site-header-height yet, so the
         * 77px fallback is what applies today. If the header ever publishes the
         * token this picks it up with no edit here. 24px is the intended gap
         * between header and pinned image.
         */
        top: calc(var(--site-header-height, 77px) + 24px);
        align-self: start;
    }
}

/*
 * The photo stage. Product art is transparent PNG, so the stage gradient is
 * what makes an un-retouched pack shot look deliberate rather than unfinished.
 */
.pdp__stage {
    position: relative;
    aspect-ratio: 1 / 1;
    display: grid;
    place-items: center;
    padding: 26px;
    overflow: hidden;
    border: 1px solid var(--pdp-stage-line);
    border-radius: var(--pdp-radius);
    background: var(--pdp-stage);
    box-shadow: var(--pdp-stage-shadow);
}

/*
 * EVERY PRODUCT IMAGE ON THIS SITE IS 1:1 (all 2383 product media rows are
 * 2000x2000), so image boxes are square and the art is sized to fill them.
 *
 * Sized explicitly rather than with `max-width/max-height: 100%`. Those
 * percentages do NOT resolve against a height that came from `aspect-ratio`, so
 * the width/height attributes on the <img> survived as a used height — a 600px
 * tall image box inside a stage only ~550px tall, letterboxing the art and
 * eating the stage padding. `aspect-ratio` on the image itself overrides the
 * attribute in both axes and needs no percentage.
 */
.pdp__stage img {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 1 / 1;
    object-fit: contain;
}

/*
 * No native image drag on the gallery. A trackpad press-and-move over the hero
 * (easy to do by accident with tap-to-click or three-finger drag) started the
 * browser's drag-and-drop of the <img>: the arrow became a translucent copy of
 * the photo and the shopper "dropped" it somewhere — reported 2026-09-15. The
 * markup carries draggable="false"; this is the belt to that suspender, and
 * user-select: none stops the blue selection flash a drag attempt leaves behind.
 */
.pdp__stage img,
.pdp__thumb img,
.pdp__thumb-video-still {
    -webkit-user-drag: none;
    user-select: none;
}

/* --- Stage: the frame, and the blank slot it shows while loading --------- */

/*
 * THE FRAME IS WHAT SLIDES, and it exists so the thing in motion can be seen
 * even when it is holding no picture.
 *
 * ROUND THREE OF THIS CONTROL, and the two earlier rounds are the argument for
 * it. Round two animated the <img> itself and leaned on the browser keeping the
 * OLD bitmap painted until the new one decoded, so the travelling panel was
 * never empty. Jesse tested that and rejected it: the previous photo lingering
 * for a moment reads as the wrong image being shown, which is worse to him than
 * a gap. Blanking the <img> and animating THAT is not the fix either, because an
 * invisible element sliding is no visible motion at all, which is the
 * dead-button defect round two existed to remove. Moving the animation up one
 * level avoids both: a frame is a real box that can paint a real surface, so it
 * carries an empty slot and is still plainly seen to travel.
 *
 * INVISIBLE AT REST. No border, no background, no radius in this rule — a frame
 * around a loaded photo has to look exactly like no frame at all. Everything
 * visible about it lives in the --loading rule below.
 *
 * IT CHANGES NO METRIC. It takes the grid slot the <img> used to take, at the
 * same size: the stage is square with 26px of padding, this is 100% wide at a
 * 1:1 ratio, so it is exactly the stage content box, and the <img> inside still
 * resolves its own width against that same number.
 */
.pdp__stage-frame {
    position: relative;
    display: block;
    width: 100%;
    aspect-ratio: 1 / 1;
}

/*
 * THE BLANK SLOT — what a shopper sees between pressing a chevron, or a variant
 * swatch, and the new photo decoding. Never the previous photo.
 *
 * VISIBILITY, NOT A CLEARED src. Three mechanisms were on the table and this is
 * the only one without a second failure mode of its own:
 *
 *   - CLEARING THE src fires the element's error handler, which on this page
 *     means the 404 text stand-in comes up for an image that is loading
 *     perfectly well. Worse, it would leave the error path unable to tell a
 *     deliberate blank from a real miss. The src here is always a real intended
 *     URL, so error keeps its single meaning and needs no sentinel.
 *   - OPACITY would work, but the slide already ramps opacity on this same
 *     element, and two multiplying opacity sources is something to reason about
 *     in exchange for nothing.
 *   - VISIBILITY paints nothing and keeps the box. The frame's metrics are
 *     identical blank or painted, so there is nothing to re-flow when the photo
 *     lands, and no way for this to move the buy column.
 *
 * THE SURFACE IS THE WHOLE POINT. A transparent empty frame would slide
 * invisibly, so while it is blank it paints as a defined slot: a hairline ring
 * and a flat fill, appearing in the same task as the click. That is the
 * indicator a shopper sees first — the spinner is a later escalation for a wait
 * long enough to need explaining, not the thing that makes the motion readable.
 *
 * SOLID AND FLAT, where the missing-art stand-in below is DASHED OVER DIAGONAL
 * STRIPES. Two states that looked alike would be worse than either: solid and
 * flat reads as "the picture is coming", dashed and striped as "there is no
 * picture".
 *
 * The ring is an inset box-shadow and not a border, because a border adds two
 * pixels to a box that changes state under the pointer that is clicking it, and
 * this file carries a recorded bug from exactly that. A box-shadow does not
 * participate in layout.
 *
 * It fills the whole frame rather than sitting in it as a smaller plate, so the
 * photo lands INTO the slot that was reserved for it instead of popping from one
 * size to another.
 */
/*
 * NOTHING IS PAINTED HERE ANY MORE. Jesse, 2026-08-31: the slot is to be fully
 * transparent, with no fill, no radius and no ring — the spinner alone.
 *
 * The paragraphs above argue the opposite and are kept because they record why
 * the surface existed, not because it still does. Two things it was carrying
 * are now carried by nothing, and both are worth knowing before this is tuned
 * again:
 *
 *   1. IT MADE THE SLIDE VISIBLE. The frame is the element that travels on a
 *      chevron or swatch press, and a transparent frame travels invisibly. The
 *      spinner does not move with it (see the indicator block below — staying
 *      put is deliberate), so nothing now animates across the stage.
 *   2. IT DISTINGUISHED "COMING" FROM "MISSING". Solid and flat read as a photo
 *      on its way; the dashed-over-stripes stand-in reads as no photo at all.
 *      With the fill gone, a short load and a 404 look the same until the
 *      stand-in appears.
 *
 * The sharp edge is the interaction with SPINNER_DELAY_MS = 150 in
 * pages/product-detail.js: the indicator is deliberately fused so a cached image
 * never strobes one. A cached `load` lands about a frame after the assignment,
 * so on a fast switch there is now no fill, no spinner and a hidden <img> — the
 * press paints nothing at all. That is the dead-button defect round two of this
 * control existed to remove. Dropping the fuse toward 0 would trade it back for
 * the strobe the fuse was added to stop.
 */

.pdp__stage--loading .pdp__stage-frame {
    background: transparent;
    border-radius: 0;
    box-shadow: none;
}

.pdp__stage--loading .pdp__stage-frame img {
    visibility: hidden;
}

/*
 * Text stand-in for a missing hero shot. Same reasoning as the swatch
 * fallback: most product art is not ported to the dev bucket yet, so a broken
 * image icon at 600px is the loudest possible failure. Revealed by
 * product-detail.js when the hero 404s.
 */
.pdp__stage-fallback {
    display: none;
    width: min(70%, 320px);
    aspect-ratio: 4 / 5;
    place-content: center;
    gap: 6px;
    padding: 16px;
    text-align: center;
    border: 1px dashed var(--pdp-line-2);
    border-radius: 10px;
    background: repeating-linear-gradient(45deg, #f5f5f9 0 10px, #e9e9f0 10px 20px);
}

/*
 * MISSING ART WINS OVER THE BLANK SLOT, and removing the whole frame is what
 * does it — one rule where there were nearly three.
 *
 * It hides the <img>, which is what this rule always did and still does, since
 * the <img> now lives inside. It also gives up the frame's GRID SLOT, which it
 * has to: the frame is an in-flow 1:1 grid item, so left in place it would keep
 * a row of its own and push the stand-in revealed below onto a second row,
 * outside the stage. And it guarantees the loading plate can never paint behind
 * that stand-in even if both classes were somehow on the stage at once —
 * product-detail.js drops --loading in the same statement that adds --noimg, and
 * this is the belt to those braces, exactly as the spinner rule further down is.
 */
.pdp__stage--noimg .pdp__stage-frame {
    display: none;
}

.pdp__stage--noimg .pdp__stage-fallback {
    display: grid;
}

.pdp__stage-fallback-name {
    font-size: clamp(15px, 2.4vw, 21px);
    font-weight: 800;
    letter-spacing: -0.02em;
    line-height: 1.15;
    color: var(--pdp-ink);
}

.pdp__stage-fallback-sku {
    font-size: 12px; /* was 10px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--pdp-ink-3);
}

/* ECOM-083: the server-decided New / Popular / sale claims share one flex row.
   Hidden per-variant pills collapse without leaving an offset, so Popular sits
   next to New when both apply and moves to the leading edge when New does not. */
.pdp__stage-badges {
    position: absolute;
    top: 12px;
    left: 12px;
    z-index: 2;
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    max-width: calc(100% - 24px);
}

.pdp__stage-badge {
    position: relative;
    padding: 5px 10px;
    border-radius: 7px;
    background: var(--pdp-accent-fill);
    color: var(--pdp-on-accent);
    font-size: 12px; /* was 10.5px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}

.pdp__stage-badge--popular {
    background: var(--pdp-ink);
    color: var(--pdp-card);
}

/* ECOM-081: the promotion flag uses the same box and tokens; deal details open
   as a tooltip on hover or keyboard focus. */
.pdp__stage-badge--sale {
    position: relative;
}

.pdp__stage-badge--sale:focus-visible {
    outline: 2px solid var(--pdp-on-accent);
    outline-offset: 2px;
}

.pdp__stage-badge-info {
    position: absolute;
    top: calc(100% + 6px);
    left: 0;
    z-index: 3;
    min-width: 200px;
    max-width: 280px;
    padding: 8px 10px;
    border-radius: 6px;
    background: var(--pdp-ink-1, #111);
    color: #fff;
    font-size: 12px;
    font-weight: 400;
    letter-spacing: 0;
    text-transform: none;
    line-height: 1.35;
    display: none;
}

.pdp__stage-badge--sale:hover .pdp__stage-badge-info:not([hidden]),
.pdp__stage-badge--sale:focus-within .pdp__stage-badge-info:not([hidden]) {
    display: block;
}

/* --- Stage: loading indicator -------------------------------------------- */

/*
 * DEAD CENTER ON THE STAGE, AND OUT OF FLOW.
 *
 * `position: absolute` inside the stage's `position: relative` — the same
 * mechanism .pdp__stage-badge already uses, and the reason this cannot shift
 * layout: an absolutely positioned child of a grid container is NOT a grid item,
 * so it takes no row and the stage's own metrics are untouched whether it is
 * showing or not.
 *
 * Centered with negative margins rather than `transform: translate(-50%, -50%)`
 * BECAUSE THE SPIN IS A TRANSFORM. A rotate animation on an element whose
 * position depends on a translate throws the translate away for the duration and
 * the ring orbits the center instead of sitting on it. Fixed offsets have no such
 * conflict, and half of 34px is a whole pixel either way.
 *
 * `pointer-events: none`: it paints above the chevrons and the zoom control (z 4
 * against their 3), so without this a shopper who clicked Next twice quickly
 * would have the second click land on the spinner.
 *
 * IT STAYED A CHILD OF THE STAGE when the slide moved onto .pdp__stage-frame,
 * rather than being re-parented into the frame so it could travel. Three reasons,
 * and the first is the real one: the frame's visible slot is what makes the
 * movement readable, so the indicator has no motion duty to perform. Second, the
 * fuse in product-detail.js is 150ms of the slide's 180ms, so by the time this
 * appears the frame has all but parked and there would be nothing to travel with.
 * Third, an animating transform makes the frame a stacking context, which would
 * trap this element's z-index inside it and put it UNDER the chevrons for the
 * length of every step. Staying put keeps all of that from being a question.
 */
.pdp__stage-spinner {
    position: absolute;
    top: 50%;
    left: 50%;
    z-index: 4;
    width: 34px;
    height: 34px;
    margin: -17px 0 0 -17px;
    border: 3px solid rgba(22, 22, 25, 0.16);
    border-top-color: var(--pdp-accent);
    border-radius: 50%;
    pointer-events: none;
}

/*
 * Said out loud. The element declares no `display`, so the UA's
 * `[hidden] { display: none }` would in fact win here — but this file has
 * already been burned once by an author `display` quietly overriding `hidden`
 * (see .pdp__stage-nav[hidden]), and the indicator is the last thing that should
 * be left painted over a finished image.
 */
.pdp__stage-spinner[hidden] {
    display: none;
}

/*
 * MISSING ART WINS, ALWAYS.
 *
 * Unported product art is the NORMAL case on dev, so the failure path is the one
 * most likely to be seen. product-detail.js clears the indicator on `error` as
 * well as on `load`, and this is the belt to that braces: while the stage is
 * showing its text stand-in there is nothing loading, so there is nothing to
 * indicate — no arrangement of JS bugs can leave a spinner turning forever over
 * a 404.
 */
.pdp__stage--noimg .pdp__stage-spinner {
    display: none;
}

@media (prefers-reduced-motion: no-preference) {
    .pdp__stage-spinner,
    .pdp__lightbox-spinner {
        animation: pdp-spin 0.7s linear infinite;
    }
}

/*
 * Asked for less motion: a STATIC indicator, not a slower spin. The ring keeps
 * its accented quadrant, so it still reads as "something is happening here"
 * without anything moving.
 */
@media (prefers-reduced-motion: reduce) {
    .pdp__stage-spinner,
    .pdp__lightbox-spinner {
        animation: none;
    }
}

@keyframes pdp-spin {
    to { transform: rotate(360deg); }
}

/* --- Stage: the slide between views -------------------------------------- */

/*
 * A ONE-WAY SLIDE, driven by a class product-detail.js puts on the stage for the
 * length of the animation.
 *
 * A KEYFRAME ANIMATION AND NOT A TRANSITION, deliberately. A transition needs the
 * element primed at the off-screen position in one frame and released in the
 * next, which means a forced reflow and a double requestAnimationFrame to be
 * reliable — three things to get wrong in code nobody has executed yet. An
 * animation just runs when the class lands.
 *
 * DIRECTION MATCHES THE CONTROL: Next brings the new view in from the RIGHT
 * (the direction it came from in the strip), Previous from the LEFT. The class
 * names say which end the image ENTERS from, so they read the same way round as
 * the arrows do.
 *
 * THE FRAME MOVES, NOT THE <img>. See .pdp__stage-frame above for why: the
 * outgoing photo is blanked the instant a new one is requested, so the <img> has
 * nothing to show for part of the travel and animating it would be animating
 * nothing. The frame paints a visible slot while it is empty, so the movement is
 * readable from the first frame whether the photo has arrived or not.
 *
 * ONLY THE INCOMING VIEW MOVES. There is one frame on the stage, so an
 * out-then-in pair would cost two animations and roughly double the wait for a
 * photo swap. The opacity ramp softens the frame's arrival; it is no longer
 * covering a seam, because there is no outgoing picture left to seam against.
 *
 * TRANSFORM AND OPACITY ONLY. No width, no padding, no border-width, no
 * font-weight — a transform does not participate in layout at all, so this
 * cannot move anything else on the page. That is the same rule the chevrons'
 * state rules follow, and for the same recorded reason.
 *
 * 26%, not 100%: this is a product photo swapping views, not a hero carousel
 * panning between slides. Enough travel to read as directional, short enough
 * that the picture is never mostly off-stage.
 *
 * THE WHOLE THING IS INSIDE prefers-reduced-motion: no-preference, so a reader
 * who asked for less motion gets the instant swap that shipped before this — not
 * a shorter slide. product-detail.js checks the same query and skips the class
 * (and the delay it implies) rather than relying on this alone.
 */
@media (prefers-reduced-motion: no-preference) {
    .pdp__stage--slide-next .pdp__stage-frame {
        animation: pdp-stage-slide-next 0.18s ease-out;
    }

    .pdp__stage--slide-prev .pdp__stage-frame {
        animation: pdp-stage-slide-prev 0.18s ease-out;
    }
}

@keyframes pdp-stage-slide-next {
    from { transform: translateX(26%); opacity: 0; }
    to { transform: translateX(0); opacity: 1; }
}

@keyframes pdp-stage-slide-prev {
    from { transform: translateX(-26%); opacity: 0; }
    to { transform: translateX(0); opacity: 1; }
}

/* --- Stage controls: chevrons and zoom ----------------------------------- */

/*
 * The chevrons and the zoom control sit ON the stage, inset far enough from its
 * edge that the 3px focus ring plus its 2px offset is never clipped by the
 * stage's own `overflow: hidden`.
 *
 * NOTHING IN THIS BLOCK CHANGES A BOX ON INTERACTION. No hover/focus/active rule
 * touches width, height, padding, border-width or font-weight — the whole state
 * story is border-color, background-color, opacity and an outline. That is not
 * fussiness: a `font-weight: 700` on a selected control in this codebase once
 * widened its own text, wrapped a line, grew a grid row and made the page jump
 * out from under the pointer that clicked it.
 */
/*
 * The per-variant chevron wrapper exists only to carry data-pdp-variant-scope —
 * it must not become a grid item. `.pdp__stage` is `display: grid` with default
 * auto-placement, so an in-flow wrapper would take a row of its own and push the
 * product photo up out of the stage.
 *
 * `display: contents` removes the box and leaves the two buttons positioning
 * against `.pdp__stage` itself, so their insets mean what they say. The wrapper
 * carries no role and no label, so nothing is lost from the accessibility tree.
 *
 * THE [hidden] RULE BELOW IS LOAD-BEARING, NOT TIDINESS. `[hidden] {display:none}`
 * comes from the UA stylesheet, and any author declaration beats it — so without
 * this, `display: contents` would override `hidden` and every variant's chevrons
 * would show at once. Exactly the reason `.pdp__gallery-thumbs[hidden]` exists.
 */
.pdp__stage-nav {
    display: contents;
}

.pdp__stage-nav[hidden] {
    display: none;
}

.pdp__stage-nav-btn,
.pdp__stage-zoom {
    position: absolute;
    z-index: 3;
    display: grid;
    place-items: center;

    /*
     * 40px, comfortably over the 24x24 minimum target size (WCAG 2.5.8) and
     * still small enough not to sit on the product art at mobile widths.
     */
    width: 40px;
    height: 40px;
    padding: 0;
    border: 1px solid var(--pdp-line-2);
    border-radius: 50%;

    /* Semi-opaque so the art behind stays readable through the control. */
    background: rgba(255, 255, 255, 0.9);
    color: var(--pdp-ink);
    cursor: pointer;
    transition: background-color 0.15s ease, border-color 0.15s ease;
}

.pdp__stage-nav-btn:hover,
.pdp__stage-zoom:hover {
    border-color: var(--pdp-accent);
    background: #fff;
}

.pdp__stage-nav-btn--prev {
    top: 50%;
    left: 12px;
    transform: translateY(-50%);
}

.pdp__stage-nav-btn--next {
    top: 50%;
    right: 12px;
    transform: translateY(-50%);
}

/*
 * The end of the list. Opacity only — a real `disabled` attribute would take the
 * button out of the tab order at the exact moment a keyboard user pressed it,
 * so the markup uses aria-disabled and this is what that looks like.
 */
.pdp__stage-nav-btn[aria-disabled="true"],
.pdp__lightbox-nav[aria-disabled="true"] {
    opacity: 0.35;
    cursor: default;
}

.pdp__stage-nav-btn[aria-disabled="true"]:hover,
.pdp__lightbox-nav[aria-disabled="true"]:hover {
    border-color: var(--pdp-line-2);
    background: rgba(255, 255, 255, 0.9);
}

/*
 * Chevron glyph, drawn rather than shipped as an asset — same approach as the
 * video tile's play triangle. Two borders on a rotated square: the rotation is
 * static, so there is no motion here for prefers-reduced-motion to opt out of.
 *
 * Named for the glyph, not for the stage, because the lightbox's arrows use it
 * too — and a class called `pdp__stage-nav-chevron` inside a dialog is both a
 * lie and, as a substring of `pdp__stage-nav`, a trap for anything grepping the
 * markup for whether the stage has chevrons at all.
 */
.pdp__chevron {
    width: 9px;
    height: 9px;
    border-style: solid;
    border-width: 2px 2px 0 0;
    border-color: currentColor;
}

.pdp__stage-nav-btn--prev .pdp__chevron,
.pdp__lightbox-nav--prev .pdp__chevron {
    /* Points left; nudged so the glyph's optical center lands in the circle. */
    transform: translateX(2px) rotate(-135deg);
}

.pdp__stage-nav-btn--next .pdp__chevron,
.pdp__lightbox-nav--next .pdp__chevron {
    transform: translateX(-2px) rotate(45deg);
}

/* Bottom-right, clear of the "New" badge at top-left. */
.pdp__stage-zoom {
    right: 12px;
    bottom: 12px;
    text-decoration: none;
}

/*
 * NO ZOOM CONTROL OVER MISSING ART.
 *
 * Product art that has not been ported to the dev bucket is the NORMAL case, so
 * an always-present zoom control would spend most of its life being a button
 * that can only open an empty box. Driven entirely by the class the existing
 * hero load/error handler already maintains in BOTH directions, so the control
 * comes back by itself the moment a variant with real art is selected.
 *
 * display:none rather than a disabled state on purpose: it also removes the
 * control from the tab order, so a keyboard user is not offered it either.
 *
 * The CHEVRONS deliberately stay: stepping to another view is still meaningful
 * when the current one is missing — the next one may well exist, and the strip's
 * text stand-ins keep every view identifiable either way.
 */
.pdp__stage--noimg .pdp__stage-zoom {
    display: none;
}

/*
 * Magnifier. THE GLYPH ITSELF IS AN INLINE SVG IN THE BLADE, not CSS — the lens
 * and the handle have to share one coordinate space or they drift apart, which is
 * exactly what happened to the pair of CSS boxes this replaced. See the long note
 * beside the <svg> in shared/catalog/product.blade.php for the arithmetic.
 *
 * All that is left here is the box the glyph occupies, restated in CSS so the
 * size is authored in one place a reader will look and cannot be changed by a
 * font-size or a box-sizing rule elsewhere. `display: block` kills the inline
 * baseline gap; the <a> is `display: grid; place-items: center`, which centers it.
 *
 * DELIBERATELY NO STATE RULES. Nothing in this file changes this element's width,
 * height, padding, border-width or font-weight on hover, focus or active — the
 * <a>'s own hover rule moves border-color and background-color only, and its
 * focus ring is an outline, which reserves no space. That is the recorded
 * page-jump bug, not fussiness.
 */
.pdp__stage-zoom-icon {
    display: block;
    width: 16px;
    height: 16px;
}

/* --- Thumbnail strip ----------------------------------------------------- */

.pdp__gallery-thumbs {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    padding: 0;
}

.pdp__gallery-thumbs[hidden] {
    display: none;
}

/* Square, like every other image box on the page — see .pdp__stage img. */
.pdp__thumb {
    flex: none;
    width: 78px;
    aspect-ratio: 1 / 1;
    padding: 5px;
    display: grid;
    place-items: center;
    overflow: hidden;
    border: 2px solid var(--pdp-line);
    border-radius: 11px;
    background: var(--pdp-stage);
    cursor: pointer;
    transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.pdp__thumb:hover {
    border-color: var(--pdp-accent);
}

.pdp__thumb--active,
.pdp__thumb[aria-pressed="true"] {
    border-color: var(--pdp-accent);
    box-shadow: 0 0 0 2px var(--pdp-accent-soft);
}

.pdp__thumb img {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 1 / 1;
    object-fit: contain;
}

/*
 * Text stand-in for a missing thumb, revealed by product-detail.js on error.
 * The strip carried none before, and because the <img> is decorative
 * (alt="") a browser paints NOTHING for a failed one — the strip looked
 * like two empty boxes rather than a broken image.
 */
.pdp__thumb-fallback {
    display: none;
    padding: 0 3px;
    font-size: 12px; /* was 9.5px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 800;
    line-height: 1.2;
    text-transform: uppercase;
    letter-spacing: 0.02em;
    color: var(--pdp-ink-2);
}

.pdp__thumb--noimg {
    background: repeating-linear-gradient(45deg, #f4f4f8 0 7px, #eaeaf1 7px 14px);
}

.pdp__thumb--noimg img {
    display: none;
}

.pdp__thumb--noimg .pdp__thumb-fallback {
    display: block;
}

/*
 * VIDEO TILE — the last tile in the strip, a jump link to the video band.
 *
 * A real <a>, so it inherits the shared .pdp__thumb box and needs only the
 * dark plate that marks it as "not a photo". Deliberately NOT square-cropped
 * art: there is no per-variant video poster in the schema (see
 * CatalogController::buildGalleryMap), so this is a signpost, not a preview.
 *
 * The dark plate matches the video facades in pages/pdp-media.css, which is
 * where the customer lands — the tile looks like its destination.
 */
/*
 * A FRAME OF THE VIDEO, NOT A BLACK SQUARE.
 *
 * This tile sits at the end of a strip of product photographs and used to be a
 * flat --pdp-ink fill. Jesse, 2026-08-31: it read as a missing image rather than
 * as a control, which is fair — it was the only tile in the rail with nothing in
 * it. The still comes from the same resolved videos section the link jumps to
 * (see the $videoTilePoster block in shared/catalog/product.blade.php), so it is
 * a picture of what you actually land on.
 *
 * The ink fill STAYS as the base layer. It is what the still is composited over,
 * it is the fallback when a page somehow has no poster, and it is what makes the
 * contrast below a floor rather than a hope.
 */
.pdp__thumb--video {
    position: relative;
    display: grid;
    grid-template-rows: 1fr auto;
    gap: 2px;
    align-content: center;
    justify-items: center;
    background: var(--pdp-ink);
    border-color: var(--pdp-ink);
    text-decoration: none;
}

.pdp__thumb--video:hover {
    border-color: var(--pdp-accent);
}

.pdp__thumb-video-still {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    opacity: 0.62;
}

/*
 * THE SCRIM IS WHAT MAKES THE WHITE LEGIBLE, and it is sized from arithmetic
 * rather than from looking at one poster.
 *
 * A video still is arbitrary photography — the next import can put a white
 * studio shot behind this text. So the numbers below are computed against the
 * WORST case, a pure white still: composited at 0.62 over #161619 and then under
 * this gradient, white text lands at 4.06:1 at the top of the tile, 7.23:1 where
 * the play triangle sits, and 16.0:1 in the band under the label. Real posters
 * are photographs and darker than that, so those are floors and not estimates.
 *
 * 4.06:1 at the very top clears the 3:1 that WCAG asks of a graphic, which is
 * all that reaches up there; the label — the only real text — sits in the 16:1
 * band. Lowering the scrim or raising the still's opacity spends that margin, so
 * recompute before doing it.
 */
.pdp__thumb--video::after {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(
        to bottom,
        rgba(22, 22, 25, 0.28) 0%,
        rgba(22, 22, 25, 0.55) 50%,
        rgba(22, 22, 25, 0.92) 100%
    );
    pointer-events: none;
}

/* Above the still and the scrim; both are earlier children of the same box. */
.pdp__thumb-video-icon,
.pdp__thumb-video-label {
    position: relative;
    z-index: 1;
}

/* Play triangle, drawn rather than shipped as an asset. */
.pdp__thumb-video-icon {
    align-self: end;
    width: 0;
    height: 0;
    border-style: solid;
    border-width: 9px 0 9px 15px;
    border-color: transparent transparent transparent #fff;
}

.pdp__thumb-video-label {
    align-self: start;
    font-size: 12px; /* was 9.5px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 800;
    line-height: 1.2;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: #fff;
}

/*
 * SMOOTH SCROLL for the video tile's in-page jump, and ONLY behind
 * no-preference. `scroll-behavior` has to sit on the SCROLLING box, which for a
 * fragment link is the document — so it goes on the root element. That is not a
 * site-wide change: this stylesheet is only ever loaded on the PDP (see the
 * @push('styles') block in shared/catalog/product.blade.php). Gated so anyone
 * who has asked for reduced motion gets the instant jump a fragment link
 * normally performs. `scroll-margin-top` keeps the section heading clear of the
 * viewport edge once it lands.
 */
@media (prefers-reduced-motion: no-preference) {
    html:has(.pdp-page) {
        scroll-behavior: smooth;
    }
}

.pdp__section[id] {
    /*
     * Same sticky-header clearance as .pdp__gallery above. At a bare 24px an
     * anchor jump landed the section heading behind the 77px header. Matches
     * the convention already used elsewhere for exactly this (legal.css uses
     * 100px "clears any sticky header on anchor-jump"; faq/international 6rem).
     */
    scroll-margin-top: calc(var(--site-header-height, 77px) + 24px);
}

/* One focus treatment for every interactive control on the page. */
.pdp__thumb:focus-visible,
.pdp__stage-nav-btn:focus-visible,
.pdp__stage-zoom:focus-visible,
.pdp__lightbox-nav:focus-visible,
.pdp__lightbox-close:focus-visible,
.pdp__pack:focus-visible,
.pdp__add-btn:focus-visible,
.pdp__qty-minus:focus-visible,
.pdp__qty-plus:focus-visible,
.pdp__qty-input:focus-visible,
.pdp__info-toggle:focus-visible,
.pdp__find-store:focus-visible,
.pdp__notify-input:focus-visible,
.pdp__notify-btn:focus-visible,
.pdp__retailer-tile:focus-visible,
.pdp__retailer-select:focus-visible,
.pdp-stickybar__btn:focus-visible {
    outline: 3px solid var(--pdp-focus);
    outline-offset: 2px;
}

/*
 * The swatch is the odd one out: the control is the <a>, but the card around it
 * is the <li>, and the <li> has `overflow: hidden` — so the ring is drawn INSIDE
 * the link rather than around it, because an outset one would be clipped by the
 * very box it is meant to trace.
 *
 * Deliberately not `.pdp__variant-item:has(.pdp__variant-link:focus-visible)`,
 * which would read better and put the ring on the card. :has() is unsupported
 * in Firefox before 121, and "no visible focus indicator on the primary control
 * of the buy box" is not a progressive-enhancement failure — it is a WCAG 2.4.7
 * failure. The rest of this file uses :has() only for scroll-behaviour, where
 * losing it costs nothing.
 */
.pdp__variant-link:focus-visible {
    outline: 3px solid var(--pdp-focus);
    outline-offset: -3px;
}

/* --- Buy box ------------------------------------------------------------- */

/*
 * NOT sticky. The GALLERY is the sticky column now (see .pdp__gallery): this one
 * carries the description as its last block and is therefore the tall one, so it
 * has to scroll for the photo beside it to have anything to stay level with.
 * Two sticky columns would simply pin the whole fold in place.
 */
.pdp__details {
    display: grid;
    gap: var(--pdp-gap);
    align-content: start;
    min-width: 0;
}

.pdp__name {
    margin: 0;
    font-size: clamp(22px, 3.4vw, 30px);
    font-weight: 800;
    letter-spacing: -0.015em;
    line-height: 1.15;
    color: var(--pdp-ink);
}

.pdp__subtitle {
    margin: 6px 0 0;
    font-size: 15px;
    color: var(--pdp-ink-2);
}

/* --- Price + SKU line ---------------------------------------------------- */

/* Jesse, 2026-09-09: price on the left, quantity + Add to Cart on the right of
   the same row; wraps to two rows when the column is narrow. The controls
   center against the price AND the SKU line, so the stock note and the add
   feedback are taken out of the row's flow (absolute, below) and the block
   reserves the line they need. A little more air above separates the row from
   the description. */
.pdp__price-block {
    display: flex;
    /* Top, not center (2026-09-24): the cart messages grow the right column
       downward, and centering moved the price and the button with them. The
       button's resting offset lives on .pdp__add-to-cart below. */
    align-items: flex-start;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--pdp-gap-sm) var(--pdp-gap);
    margin-top: 6px;
    padding: 10px 0 24px;
    border-top: 1px solid var(--pdp-line);
}

.pdp__price-main {
    display: grid;
    gap: 2px;
    align-self: flex-start;
}

.pdp__price-block .pdp__add-to-cart {
    position: relative;
    margin-left: auto;
    /* Where centering used to put the button beside the price and SKU (65px
       column, 41px row), held fixed so the messages below never move it. */
    align-self: flex-start;
    margin-top: 12px;
}

/* The messages under the button, stacked (2026-09-24). In the flow, so they
   push what is below down instead of printing over it; as wide as the cart
   controls, right aligned, and allowed to wrap, so a long stock error stays
   under the button instead of running across the photo. The stack is empty
   (and takes no room) until one of them has something to say. */
.pdp__price-block .pdp__cart-messages {
    display: grid;
    gap: 2px;
    justify-items: end;
    text-align: right;
    margin-top: 6px;
    /* As wide as the button row, never wider: a long message wraps inside it
       instead of widening the cart controls onto a row of their own. */
    width: 0;
    min-width: 100%;
}

.pdp__price-block .pdp__cart-messages:not(:has(> :not([hidden]):not(:empty))) {
    display: none;
}

.pdp__price-block .pdp__add-feedback {
    min-height: 0;
}

.pdp__price-block .pdp__add-feedback:empty {
    display: none;
}

.pdp__price-block .pdp__add-btn {
    flex: 0 0 auto;
    padding: 10px 16px;
    font-size: 14px;
}

.pdp__price-block .pdp__qty-minus,
.pdp__price-block .pdp__qty-plus {
    width: 34px;
}

.pdp__price-block .pdp__qty-input {
    width: 44px;
}

.pdp__price {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--pdp-gap-sm);
}

.pdp__price--current,
.pdp__price--sale {
    font-size: 30px;
    font-weight: 800;
    letter-spacing: -0.02em;
    color: var(--pdp-ink);
}

/* Affirm's line under the price (docs/features/affirm.md): Affirm writes the
   text; its monthly amount is shown large in the brand's accent text color, as
   Magento did. --brand-accent-text is AA on white in every brand. */
.pdp__affirm {
    margin: 6px 0 0;
    font-size: 15px;
    line-height: 1.5;
    color: var(--pdp-ink);
}

.pdp__affirm .affirm-ala-price {
    font-size: 24px;
    font-weight: 800;
    color: var(--brand-accent-text);
}

.pdp__price--original {
    font-size: 15px;
    color: var(--pdp-ink-3);
    text-decoration: line-through;
}

.pdp__sku {
    font-size: 12px;
    color: var(--pdp-ink-3);
}

/* --- Selector blocks (variant rail, pack sizes) -------------------------- */

.pdp__variants,
.pdp__packs {
    padding: var(--pdp-gap);
    border: 1px solid var(--pdp-line);
    border-radius: var(--pdp-radius);
    background: var(--pdp-card);
}

.pdp__packs[hidden] {
    display: none;
}

.pdp__sel-head {
    display: flex;
    align-items: baseline;
    gap: var(--pdp-gap-sm);
    margin-bottom: 11px;
}

.pdp__sel-head .pdp__sel-value {
    margin-left: auto;
}

/* Heading with the "★ Popular" key as a small line beneath it. */
.pdp__sel-title {
    display: flex;
    flex-direction: column;
    gap: 3px;
    min-width: 0;
}

.pdp__variants-heading,
.pdp__sel-label {
    margin: 0;
    font-size: 12.5px;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--pdp-ink-3);
}

.pdp__sel-value {
    font-size: 13.5px;
    font-weight: 700;
    text-align: right;
    color: var(--pdp-ink);
}

/*
 * THE RAIL WRAPS. IT IS NOT A SCROLL BOX.
 *
 * This used to carry `max-height: 24rem; overflow-y: auto` on top of the grid
 * below, to stop 70+ SKU pages pushing the buy column off the fold. Three
 * declarations, and they cost more than they saved: 82 of the 123 EB
 * multi-variant pages have six variants or fewer, where the cap can never be
 * reached and the box is a scroll affordance on content that does not scroll.
 * On the pages that genuinely did overflow it was worse — a buying control
 * hidden behind a scrollbar, wedged between the price and Add to Cart.
 *
 * The grid was already responsive, so removing the cap is the whole fix up to
 * roughly 34 variants. Beyond that the original concern is real, and it is
 * handled by --clamped plus a visible button rather than by a scrollbar
 * somebody has to discover by dragging.
 *
 * Nothing here touches an href, a handler or a data-* attribute, so the
 * per-variant URL contract (docs/pdp-variant-urls.md) is unaffected.
 */
.pdp__variant-list {
    list-style: none;
    padding: 0;
    margin: 0;
    display: grid;
    gap: 8px;
}

/*
 * FIXED COLUMN COUNT, NOT FIXED TILE WIDTH.
 *
 * The first attempt tiered the tile MIN-WIDTH by variant count (84 / 74 / 64px)
 * and let auto-fill decide the columns. That produced two faults the owner saw
 * immediately: the column count then varies per page (a 3-variant rail and a
 * 73-variant rail resolve differently at the same viewport, so the buy column
 * visibly re-flows as you move between products), and 64px is narrower than the
 * labels — measured, the p90 label is 12 characters ("10.5-48 Gauge" is 13) and
 * they were being cut off.
 *
 * So the columns are fixed and the tile width follows. Every rail on every page
 * lines up at a given breakpoint, and the tile is whatever that division gives,
 * which at the buy column's width clears 13 characters comfortably.
 *
 * Count is NOT tiered by variant count any more. Density is a viewport
 * question, not a catalog-size question — the 73-variant page is longer, not
 * differently shaped, and the disclosure below handles length.
 */
.pdp__variant-list {
    grid-template-columns: repeat(3, minmax(0, 1fr));
}

/*
 * Four columns, not five — but the count follows the CONTAINER, not the page.
 *
 * Five fitted the gauge labels that dominate the catalog (p90 is 12 characters)
 * but clipped the accessory pages, where a colorway runs to 26 — "Red & Blue
 * Peace Love Dove" on the Jacquard straps. A quarter of the buy column holds
 * that; a fifth does not. Trading one swatch per row for labels that fit is the
 * right way round: a clipped name costs the shopper the one thing the swatch
 * exists to tell them.
 *
 * Uniform per breakpoint, never per page. Making the count depend on variant
 * count or label length reintroduces the drift that tiering already caused
 * once — rails that reflow as you move between products on the same screen.
 *
 * THE 900-1100 DIP. These steps mirror .pdp__layout exactly, because what
 * actually governs the tile is the width of the buy column, not the viewport:
 *
 *   <480    stacked, narrow      3 cols
 *   480-899 stacked, full width  4 cols   (roomy — 132px at 600px)
 *   900-1099 two-col, 360px      3 cols   (115px — three in a narrow box
 *                                          beats four, and the gallery keeps
 *                                          454px instead of 374px)
 *   >=1100  two-col, 440px       4 cols   (104px)
 *
 * Strictly this is a container-query problem and would be one rule against the
 * buy column's own width. Mirrored media queries are the pragmatic version;
 * if either set moves, the other has to move with it.
 */
@media (min-width: 480px) {
    .pdp__variant-list {
        grid-template-columns: repeat(4, minmax(0, 1fr));
    }
}

@media (min-width: 900px) {
    .pdp__variant-list {
        grid-template-columns: repeat(3, minmax(0, 1fr));
    }
}

@media (min-width: 1100px) {
    .pdp__variant-list {
        grid-template-columns: repeat(4, minmax(0, 1fr));
    }
}

/*
 * `minmax(0, 1fr)` rather than `1fr`: a grid track's default min is auto, which
 * refuses to shrink below its content, so one 41-character outlier ("USB-C
 * Rechargeable 9v Battery by Paleblue") would widen its whole column and knock
 * the rail out of alignment. The 0 min lets the track hold its share and lets
 * the label wrap inside it instead.
 */

/*
 * The long tail, clamped by ROWS rather than a height in ems, so it holds at
 * any tile size and any viewport width instead of guessing.
 *
 * Applied by JS only. With JS off, or before the module loads, every variant is
 * present and reachable — the same contract the swatches themselves follow:
 * the links are the truth, the enhancement is an optimisation. `grid-auto-rows:
 * 0` collapses the implicit rows past the clamp; `overflow: hidden` keeps them
 * from painting. The module also sets `inert` on the hidden items so they leave
 * the tab order while collapsed, which `overflow: hidden` alone does not do.
 */
.pdp__variant-list--clamped {
    grid-template-rows: repeat(var(--pdp-variant-rows, 4), auto);
    grid-auto-rows: 0;
    overflow: hidden;
}

.pdp__variant-more {
    display: block;
    width: 100%;
    margin-top: 10px;
    padding: 9px 12px;
    border: 1px solid var(--pdp-line, #d8d8d8);
    border-radius: 8px;
    background: none;
    font: inherit;
    font-size: 0.875rem;
    font-weight: 600;
    color: var(--pdp-ink);
    cursor: pointer;
}

.pdp__variant-more:hover {
    border-color: currentColor;
}

.pdp__variant-more:focus-visible {
    outline: 2px solid currentColor;
    outline-offset: 2px;
}

.pdp__variant-item {
    position: relative;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    border: 2px solid var(--pdp-line);
    border-radius: 11px;
    background: var(--pdp-card);
    text-align: center;
    transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

/*
 * The swatch is a real <a href> to the variant's own URL and the <li> is only
 * the card around it, so the link has to fill the card — otherwise the hit area
 * is the text, not the tile, and the tick in the corner sits outside the
 * control. No `cursor: pointer` here: an anchor with an href already has one,
 * and adding it by hand is how a non-link ends up looking clickable.
 */
.pdp__variant-link {
    display: flex;
    flex: 1;
    flex-direction: column;
    color: inherit;
    text-decoration: none;
}

/*
 * Swatches for pack sizes other than the selected one. Every SKU on the page is
 * pre-rendered so its server-computed availability travels with it, and the rail
 * shows one entry per GAUGE by hiding the rest — the attribute rather than a
 * class so assistive tech and find-in-page agree with what is painted.
 */
.pdp__variant-item[hidden] {
    display: none;
}

.pdp__variant-item:hover {
    border-color: var(--pdp-accent);
}

/*
 * Selected state has to survive a swatch grid with no artwork, so it reads on
 * three channels: border ring, tinted label, and a tick — not colour alone.
 *
 * Driven by the class alone now. It used to also match [aria-checked="true"],
 * which the rail no longer emits: the swatches are links marked
 * aria-current="page", not radios (§2.4). The class is on the <li> and the
 * attribute is on the <a>, so the two could not have been one selector anyway.
 */
.pdp__variant-item--active {
    border-color: var(--pdp-accent);
    box-shadow: 0 0 0 3px var(--pdp-accent-soft);
}

/*
 * NO FONT-WEIGHT CHANGE HERE, and that is a layout rule rather than a taste
 * one. Selection must not alter the label's METRICS.
 *
 * This used to add `font-weight: 700`. Bold glyphs are wider, so a label that
 * fit one line at 600 wrapped to two the moment it was selected — "Guitar
 * Tailpiece" was the reported case. The tile's inner width never changed; only
 * the text did. A grid row sizes to its tallest item, so that extra line grew
 * the row and pushed everything below the rail down the buy column. Clicking a
 * swatch made the page jump.
 *
 * The other two active rules are deliberately metric-free: `border-color`
 * changes the color at a constant 2px, and `box-shadow` never participates in
 * layout. Keep it that way — anything that changes width, padding, border width
 * or font-weight on selection reintroduces the jump.
 *
 * Selection is still unmistakable without the weight: accent border, accent
 * text, and the filled tick in the corner. If a heavier look is ever wanted
 * back, `-webkit-text-stroke: 0.3px currentColor` thickens the glyphs without
 * touching their advance widths — do that, not `font-weight`.
 */
.pdp__variant-item--active .pdp__variant-name {
    color: var(--pdp-accent-ink);
}

.pdp__variant-item--active::after {
    content: '';
    position: absolute;
    top: 5px;
    right: 5px;
    width: 15px;
    height: 15px;
    border-radius: 50%;
    background: var(--pdp-accent-fill);
    box-shadow: inset 0 0 0 3px var(--pdp-card), 0 0 0 1px var(--pdp-accent-fill);
}

.pdp__variant-thumb {
    position: relative;
    aspect-ratio: 1 / 1;
    display: grid;
    place-items: center;
    padding: 6px;
    border-bottom: 1px solid var(--pdp-line);
    background: var(--pdp-stage);
}

/*
 * THIS IS THE 1:1 FIX. Measured before: an 84px-wide tile rendered 201px tall
 * with only 73px of visible pack shot in it — a box nearly 3x the height of the
 * image.
 *
 * Cause: `max-height: 100%` does not resolve against a height that came from
 * `aspect-ratio`, so the <img>'s height="160" attribute survived as the used
 * height. The 160px-tall image box then became the flex item's automatic minimum
 * size (`min-height: auto` applies to flex items and .pdp__variant-thumb has
 * visible overflow), which overrode the aspect-ratio and stretched the square
 * box to 173px — while `object-fit: contain` letterboxed the 1:1 art down to
 * 73x73 inside it.
 *
 * Sizing the image explicitly, with its own aspect-ratio instead of a
 * percentage, overrides the attribute in BOTH axes: the tile measures 114px for
 * an 86px square of art that fills it. Do not go back to max-width/max-height
 * here.
 */
.pdp__variant-thumb img {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 1 / 1;
    object-fit: contain;
}

/*
 * Text fallback for a missing pack shot. Hidden until product-detail.js flags
 * the image as failed — product art that hasn't been ported to the bucket yet
 * is the normal case on dev, and a grid of broken-image icons would be worse
 * than no image at all.
 */
.pdp__variant-thumb-fallback {
    display: none;
    padding: 0 3px;
    font-size: 12px; /* was 10.5px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 800;
    line-height: 1.2;
    letter-spacing: -0.01em;
    color: var(--pdp-ink);
    word-break: break-all;
}

.pdp__variant-item--noimg .pdp__variant-thumb {
    background: repeating-linear-gradient(45deg, #f4f4f8 0 7px, #eaeaf1 7px 14px);
}

.pdp__variant-item--noimg .pdp__variant-thumb img {
    display: none;
}

.pdp__variant-item--noimg .pdp__variant-thumb-fallback {
    display: block;
}

/* "New" marker on a swatch — the stage badge, scaled to the tile. */
/* Staff preview marker on a swatch (2026-09-21): amber, bottom-left of the
   thumbnail. NOT top-right: on a 96px tile that sat flush against the New pill
   and the tile's edge cropped it to "REVIEW". Same size and weight as New. */
.pdp__variant-preview {
    position: absolute;
    bottom: 4px;
    left: 4px;
    max-width: calc(100% - 8px);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    z-index: 1;
    padding: 2px 6px;
    border-radius: 5px;
    background: var(--pdp-warn);
    color: #fff;
    font-size: 12px; /* was 9.5px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    line-height: 1.3;
}

/* The notice above the price row for a previewed variant. Amber on its soft
   tone (the availability tokens, already measured ≥ 4.5:1), a solid left rule
   so it reads as a caution, not as marketing. Per-variant, hidden by the
   reveal contract when another variant is active. */
/*
 * A Vault instrument's in-stock or built-to-order line (Shelly, 2026-09-28:
 * "abundantly clear"), above the price. Status inks on their soft fills, the
 * pair PdpStatusContrastTest already holds at AA.
 */
.pdp__instrument-avail {
    display: grid;
    grid-template-columns: auto 1fr;
    column-gap: 8px;
    align-items: baseline;
    margin: 6px 0 12px;
    padding: 10px 14px;
    border-radius: var(--pdp-radius-sm);
    font-size: 15px;
    line-height: 1.4;
}

.pdp__instrument-avail--ready {
    background: var(--pdp-ok-soft);
    color: var(--pdp-ok);
}

.pdp__instrument-avail--built {
    background: var(--pdp-warn-soft);
    color: var(--pdp-warn);
}

.pdp__instrument-avail-dot {
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background: currentcolor;
}

.pdp__instrument-avail-detail {
    grid-column: 2;
    font-size: 13px;
}

.pdp__preview-notice {
    display: grid;
    gap: 2px;
    margin-top: 6px;
    padding: 10px 12px;
    border-left: 4px solid var(--pdp-warn);
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-warn-soft);
    color: var(--pdp-warn);
    font-size: 13px;
    line-height: 1.45;
}

.pdp__preview-notice[hidden] {
    display: none;
}

.pdp__preview-notice-title {
    font-weight: 700;
}

.pdp__variant-new {
    position: absolute;
    top: 4px;
    left: 4px;
    z-index: 1;
    padding: 2px 6px;
    border-radius: 5px;
    background: var(--pdp-accent-fill);
    color: var(--pdp-on-accent);
    font-size: 12px; /* was 9.5px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    line-height: 1.3;
}

/* ECOM-081: promotion text on a swatch — same treatment as New, parked at the
   bottom-left so both can show on one tile. */
/* Popular is a star chip top-right (New holds top-left, the promotion and the
   staff Preview marker share bottom-left). Ink-on-card like the stage badge;
   the word itself is hidden text, and the rail header carries the key. */
.pdp__variant-popular {
    position: absolute;
    top: 4px;
    right: 4px;
    z-index: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: var(--pdp-ink);
    color: var(--pdp-card);
}

.pdp__star {
    width: 11px;
    height: 11px;
    fill: currentColor;
}

/* "★ Popular" beside the options count, only while a tile on offer is starred. */
.pdp__rail-key {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 12px; /* was 11px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 600;
    color: var(--pdp-ink-3);
    white-space: nowrap;
}

.pdp__rail-key[hidden] {
    display: none;
}

.pdp__rail-key .pdp__star {
    width: 12px;
    height: 12px;
    color: var(--pdp-ink);
}

.pdp__variant-sale {
    position: absolute;
    bottom: 4px;
    left: 4px;
    z-index: 1;
    max-width: calc(100% - 8px);
    padding: 2px 6px;
    border-radius: 5px;
    background: var(--pdp-accent-fill);
    color: var(--pdp-on-accent);
    font-size: 12px; /* was 9.5px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    line-height: 1.3;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.pdp__variant-name {
    padding: 5px 3px 6px;
    font-size: 12px; /* was 11px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 600;
    line-height: 1.2;
    color: var(--pdp-ink-2);
    display: -webkit-box;
    /*
     * FOUR lines, not three.
     *
     * The character budget was never the binding constraint on its own — WORD
     * WRAPPING is what clips these. "Red & Blue Peace Love Dove" is 16
     * characters at ~101px against ~98px of usable tile, so it breaks early and
     * needs three lines; "Purple Pleasant Pheasant" is three words of 6-8
     * characters that cannot pair up. The rail also steps 3 -> 4 -> 3 -> 4
     * columns across breakpoints (see .pdp__variant-list), so the same label has
     * fewer characters per line at the four-column steps than this estimate
     * assumes.
     *
     * Measured across the 1063 rendered labels TODAY, after composition began
     * carrying a tension word, a measurement, and both cable ends: median 10,
     * p90 29, p99 41, longest 52. Only 71 exceed three lines' worth and only
     * TWO exceed four — and none exceeds five, so this clears the catalog with
     * room rather than just clearing it.
     *
     * Only rows containing a long label grow: a grid row sizes to its tallest
     * item, the tile has no fixed height, and the gauge pages that dominate the
     * catalog are one line each. Still cheaper than another column, which would
     * cost every page to fix a handful.
     *
     * The two outliers are both "10ft Black w/Gold Connectors Straight to Right
     * Angle" on Braided Cables, and they are a DATA problem rather than a layout
     * one: "w/Gold Connectors" is a colorway print name and "Straight to Right
     * Angle" is the {connectors} composite, so the label says connectors twice.
     * Redundancy suppression misses it because neither string contains the
     * other. Shortening that colorway is the real fix; four lines is what stops
     * it being clipped in the meantime.
     */
    -webkit-line-clamp: 4;
    line-clamp: 4;
    -webkit-box-orient: vertical;
    overflow: hidden;
    /* Measured: the p90 label is 12 characters and the longest is 41 ("USB-C
       Rechargeable 9v Battery by Paleblue"). Two lines at this size clears the
       p90 comfortably now that the tile is a fifth of the buy column rather
       than a 64px minimum. The outliers still clamp, but the full string is on
       the anchor's aria-label, so nothing is lost to a screen reader.
       `anywhere` stops a long unbroken token widening the tile. */
    overflow-wrap: anywhere;
}

/*
 * A swatch shows its `tiny_name` and nothing else. There was a second line here
 * carrying a qualifier derived from `short_name` for a label the page used more
 * than once; it is gone with the qualifier, because a variant is labelled by
 * `tiny_name` alone. Duplicate labels are being fixed in the data, and the SKU in
 * each swatch's aria-label is what keeps two colliding ones distinguishable in
 * the meantime.
 */

/* --- Pack-size pills ----------------------------------------------------- */

/*
 * The helper line and the per-variant pack rows are one item in the buy column's
 * grid, so the line sits tight against the box it explains instead of taking a
 * full --pdp-gap of its own. Only one pack row is ever visible ([hidden] is
 * display:none), so this grid never reserves space for the rest.
 */
.pdp__pack-axis {
    display: grid;
    gap: 7px;
}

/* The pack axis as two small pills at the top of the variant box, with the sort
   control on the same line (Jesse, 2026-09-09). Two rows: the helper line
   ("Available as Single Pack or 3-Pack") alone on the first, then the pills and
   "Sort by:" sharing the second, vertically centered on each other. The pack
   axis wrapper contributes no box of its own (display: contents), so its note
   and pill rows become flex items of this container alongside the sort. */
.pdp__variants-tools {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 6px 12px;
    flex-wrap: wrap;
    margin-bottom: 12px;
}

.pdp__pack-axis--inline {
    display: contents;
}

/* No box around the pills (Jesse, 2026-09-09): they line up with the sort control. */
.pdp__pack-axis--inline .pdp__packs {
    flex: 0 1 auto;
    padding: 0;
    border: 0;
    background: transparent;
}

.pdp__pack-axis--inline .pdp__pack-list > li {
    flex: 0 0 auto;
    min-width: 0;
}

/* Centered by flex, not by a line box: as a plain block the 12.5px title sat
   in the anchor's inherited 24px line box and read a hair low (Jesse, 2026-09-09). */
.pdp__pack-axis--inline .pdp__pack {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0 12px;
    min-height: 32px;
    border-radius: 999px;
    line-height: 1;
}

.pdp__pack-axis--inline .pdp__pack-title {
    font-size: 12.5px;
    line-height: 1;
}

.pdp__sort {
    display: flex;
    align-items: center;
    gap: 8px;
    flex: 0 0 auto;
    margin-left: auto;
}

.pdp__sort[hidden] {
    display: none;
}

.pdp__sort-label {
    font-size: 12.5px;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--pdp-ink-3);
}

.pdp__sort-select {
    padding: 6px 28px 6px 12px;
    border: 1.5px solid var(--pdp-line-2);
    border-radius: 999px;
    background: var(--pdp-card) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23555' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 10px center;
    color: var(--pdp-ink);
    font-size: 12.5px;
    font-weight: 700;
    appearance: none;
    -webkit-appearance: none;
    cursor: pointer;
}

.pdp__sort-select[hidden] {
    display: none;
}

.pdp__sort-select:focus-visible {
    outline: 2px solid var(--pdp-accent);
    outline-offset: 2px;
}

.pdp__variant-rail[hidden] {
    display: none;
}

/*
 * FIXED HEIGHT, ON PURPOSE. This line changes text on every pack switch, and the
 * copy it replaced lived inside a pill where a second line grew the buy box on
 * hover-and-read. One row is reserved on load and the height never depends on
 * the message: no wrap, and a message longer than the column is clipped rather
 * than allowed to push the buy box around. The copy is written short for exactly
 * that reason (CatalogController::packSwitchNote()).
 */
.pdp__pack-note {
    margin: 0;
    height: 1.35rem;
    line-height: 1.35rem;
    font-size: 12.5px;
    color: var(--pdp-ink-3);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* The helper line keeps the base rule's reserved height (PdpShellTest pins it:
   the row must not jump when a cross-gauge note appears); only its order moves
   below the pills. */
.pdp__pack-axis--inline .pdp__pack-note {
    /* Above the pills, on a row of its own (Jesse, 2026-09-09): "Available as
       Single Pack or 3-Pack" introduces the choice rather than trailing it, and
       the pills then share their row with "Sort by:". */
    order: -1;
    flex: 1 0 100%;
    font-size: 12px;
}


/*
 * A list of links, matching the rail (§9.2). The <li> is the flex item that
 * shares the row out; the <a> stretches to fill it so every pill in a row is
 * the same height whatever its label wraps to.
 */
.pdp__pack-list {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    list-style: none;
    margin: 0;
    padding: 0;
}

.pdp__pack-list > li {
    display: flex;
    flex: 1;
    min-width: 96px;
}

.pdp__pack {
    flex: 1;
    padding: 11px 10px;
    border: 1.5px solid var(--pdp-line-2);
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-card);
    color: var(--pdp-ink);
    text-align: center;
    text-decoration: none;
    transition: border-color 0.15s ease, background-color 0.15s ease;
}

.pdp__pack:hover {
    border-color: var(--pdp-accent);
}

.pdp__pack[aria-current="page"] {
    border-color: var(--pdp-accent);
    background: var(--pdp-accent-soft);
}

/*
 * EVERY PILL LOOKS OPERABLE, BECAUSE EVERY PILL IS. There is no disabled variant
 * of this state any more: a size the current gauge is not made in moves the
 * shopper to a gauge that is, and the helper line above says so afterwards.
 * Styling it apart would only pre-warn about something that no longer happens.
 */
.pdp__pack-title {
    font-size: 13.5px;
    font-weight: 700;
}

/* --- Availability -------------------------------------------------------- */

/* Shares the price row's right-hand slot with the cart controls and shows only
   when they are absent (Jesse, 2026-09-09). The dot and text lead; a sold-out
   variant adds the restock toggle at the far end. */
.pdp__avail {
    display: flex;
    align-items: flex-start;
    flex-wrap: wrap;
    gap: 9px;
    padding: 11px 13px;
    border-radius: var(--pdp-radius-sm);
    font-size: 13.5px;
    font-weight: 600;
}

.pdp__avail[hidden] {
    display: none;
}

.pdp__price-block .pdp__avail {
    margin-left: auto;
    align-self: center;
    max-width: 100%;
}

.pdp__avail-text {
    align-self: center;
    min-width: 0;
}

/* Dot and label on one line; the detail lines up under the label (Jesse,
   2026-10-08: the dot used to sit alone above a two-line message). */
.pdp__avail-head {
    display: flex;
    align-items: center;
    gap: 8px;
}

.pdp__avail-detail[hidden] {
    display: none;
}

/* Outlined in the callout's own ink, so it inherits the contrast each state's
   token pair is already measured for (PdpStatusContrastTest). */
.pdp__avail-toggle {
    align-self: center;
    margin-left: auto;
    padding: 6px 12px;
    border: 1.5px solid currentcolor;
    border-radius: 999px;
    background: transparent;
    color: inherit;
    font: inherit;
    font-size: 12.5px;
    font-weight: 700;
    line-height: 1.2;
    cursor: pointer;
}

.pdp__avail-toggle[hidden] {
    display: none;
}

.pdp__avail-toggle:hover {
    background: var(--pdp-card);
}

.pdp__avail-toggle:focus-visible {
    outline: 3px solid var(--pdp-focus);
    outline-offset: 2px;
}

.pdp__avail-toggle[aria-expanded="true"] {
    background: var(--pdp-card);
}

.pdp__avail-dot {
    flex: none;
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background: currentcolor;
}

.pdp__avail-detail {
    display: block;
    margin-top: 2px;
    padding-left: 17px; /* the dot plus its gap */
    font-weight: 500;
    /* Full strength on purpose — a 0.9 fade dimmed the status ink under the 4.5:1 the token is tuned for. */
}

.pdp__avail--in_stock {
    background: var(--pdp-ok-soft);
    color: var(--pdp-ok);
}

.pdp__avail--sold_out,
.pdp__avail--reserved,
.pdp__avail--discontinued,
.pdp__avail--unavailable {
    background: var(--pdp-bg-alt);
    color: var(--pdp-ink-2);
}

.pdp__avail--retailer_only {
    background: var(--pdp-warn-soft);
    color: var(--pdp-warn);
}

/* --- Shipping line ------------------------------------------------------- */

.pdp__ship-line {
    display: flex;
    align-items: center;
    /* Two spans now (origin + the optional editable promise), so the row has to
       be able to wrap on a narrow buy box instead of squeezing either one. */
    flex-wrap: wrap;
    gap: 3px 7px;
    font-size: 12.5px;
    color: var(--pdp-ink-2);
}

.pdp__ship-line[hidden] {
    display: none;
}

/* Editable shipping promise (Content Block). Sits INSIDE .pdp__ship-line so it
   inherits that element's single availability gate and the variant switch's
   setHidden() — see the note in product.blade.php.

   Weight only, no accent colour and no badge: the copy this carries is a pricing
   claim the cart does not yet enforce, so it deliberately does not get styled up
   into a promotional pill that would draw the eye to it. */
.pdp__ship-promise {
    font-weight: 600;
    color: var(--pdp-ink);
}

/* --- CTA row ------------------------------------------------------------- */

.pdp__cta-row {
    display: grid;
    gap: var(--pdp-gap-sm);
}

.pdp__add-to-cart {
    display: grid;
    gap: 6px;
}

.pdp__add-to-cart[hidden],
.pdp__notify[hidden] {
    display: none;
}

.pdp__add-to-cart-row {
    display: flex;
    align-items: stretch;
    gap: var(--pdp-gap-sm);
}

.pdp__qty-controls[hidden],
.pdp-stickybar__qty[hidden] {
    display: none;
}

.pdp__qty-controls {
    display: inline-flex;
    align-items: stretch;
    overflow: hidden;
    border: 1.5px solid var(--pdp-line-2);
    border-radius: var(--pdp-radius-sm);
}

.pdp__qty-minus,
.pdp__qty-plus {
    width: 40px;
    border: 0;
    background: var(--pdp-bg-alt);
    font-size: 1rem;
    font-weight: 700;
    color: var(--pdp-ink);
    cursor: pointer;
    transition: background-color 0.15s ease;
}

.pdp__qty-minus:hover,
.pdp__qty-plus:hover {
    background: var(--pdp-line);
}

.pdp__qty-input {
    width: 52px;
    border: 0;
    border-left: 1.5px solid var(--pdp-line-2);
    border-right: 1.5px solid var(--pdp-line-2);
    text-align: center;
    font-size: 0.9375rem;
    font-weight: 700;
    color: var(--pdp-ink);
    background: var(--pdp-card);
    -moz-appearance: textfield;
    appearance: textfield;
}

.pdp__qty-input::-webkit-outer-spin-button,
.pdp__qty-input::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

/* Scarcity line under the qty row.
   Deliberately understated: it is a statement of fact about how many units are
   on hand, not a promotion. No pill, no accent fill, no animation — the warn
   token carries the meaning and nothing here manufactures urgency the copy
   itself doesn't claim. */
.pdp__stock-note {
    margin: 0;
    font-size: 12.5px;
    font-weight: 600;
    color: var(--pdp-warn);
}

.pdp__stock-note[hidden],
.pdp__in-cart-note[hidden] {
    display: none;
}

.pdp__cart-messages {
    display: grid;
    gap: 6px;
}

.pdp__in-cart-note {
    margin: 0;
    font-size: 12.5px;
    font-weight: 600;
    color: var(--pdp-ink-2, inherit);
}

.pdp__add-btn {
    flex: 1;
    padding: 15px 18px;
    border: 0;
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-accent-fill);
    color: var(--pdp-on-accent);
    font-size: 15px;
    font-weight: 700;
    cursor: pointer;
    transition: background-color 0.15s ease;
}

.pdp__add-btn:hover {
    background: var(--pdp-accent-fill-hover);
}

.pdp__add-btn:disabled {
    background: var(--pdp-line-2);
    color: var(--pdp-ink-3);
    cursor: not-allowed;
}

.pdp__add-feedback {
    min-height: 1.25rem;
    font-size: 0.8125rem;
    line-height: 1.25rem;
}

.pdp__add-feedback--success {
    color: var(--pdp-ok);
}

.pdp__add-feedback--error {
    color: var(--pdp-accent-ink);
}

/* Secondary CTA — always offered, and the primary path when we can't sell. */
.pdp__find-store {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 14px 18px;
    border: 1.5px solid var(--pdp-line-2);
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-card);
    color: var(--pdp-ink);
    font-size: 14.5px;
    font-weight: 700;
    text-decoration: none;
}

.pdp__find-store:hover {
    border-color: var(--pdp-accent);
    color: var(--pdp-ink);
}

/* --- Notify-me (affordance only — no capture endpoint yet) --------------- */

/*
 * The restock form slides open rather than snapping (Jesse, 2026-09-09). The
 * wrapper clips and transitions its height; product-detail.js measures the
 * content and writes the target height inline, then hands back to `auto` once
 * open so Turnstile or an outcome message can still grow it. While collapsed
 * the wrapper also pulls back the column gap beneath it, so nothing below jumps
 * by the gap the instant the wrapper un-hides.
 */
.pdp__notify-reveal {
    height: 0;
    overflow: hidden;
    margin-bottom: calc(-1 * var(--pdp-gap));
    transition: height 0.3s ease, margin-bottom 0.3s ease;
}

.pdp__notify-reveal--open {
    height: auto;
    margin-bottom: 0;
}

.pdp__notify-reveal[hidden] {
    display: none;
}

/* Asked for less motion: the form still opens, it just appears. */
@media (prefers-reduced-motion: reduce) {
    .pdp__notify-reveal {
        transition: none;
    }
}

.pdp__notify {
    display: grid;
    gap: 7px;
    padding: 12px 13px;
    border: 1px dashed var(--pdp-line-2);
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-bg-alt);
}

.pdp__notify-row {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
}

.pdp__notify-input {
    flex: 1;
    min-width: 180px;
    padding: 12px;
    border: 1.5px solid var(--pdp-line-2);
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-card);
    color: var(--pdp-ink);
    font-size: 14px;
}

.pdp__notify-btn {
    flex: none;
    padding: 12px 18px;
    border: 0;
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-accent-fill);
    color: var(--pdp-on-accent);
    font-size: 14px;
    font-weight: 700;
    cursor: pointer;
}

.pdp__notify-btn:disabled {
    background: var(--pdp-line-2);
    color: var(--pdp-ink-3);
    cursor: not-allowed;
}

.pdp__notify-input:disabled {
    background: var(--pdp-bg-alt);
    color: var(--pdp-ink-3);
    cursor: not-allowed;
}

.pdp__notify-hint {
    margin: 0;
    font-size: 12px; /* was 11.5px; 12px floor — WAVE "very small text" (2026-09-22) */
    color: var(--pdp-ink-3);
}

/* --- Amazon social-proof slot ------------------------------------------- */

.pdp-amazon-proof {
    margin: 0;
    padding-top: var(--pdp-gap-sm);
    border-top: 1px solid var(--pdp-line);
    font-size: 12.5px;
    color: var(--pdp-ink-2);
}

.pdp-amazon-proof__line {
    font-weight: 700;
    color: var(--pdp-ink);
}

.pdp-amazon-proof__as-of {
    color: var(--pdp-ink-3);
}

/* --- Retailers / Buy Now ------------------------------------------------- */

.pdp__retailers {
    padding-top: var(--pdp-gap);
    border-top: 1px solid var(--pdp-line);
}

.pdp__retailers-heading {
    margin: 0 0 var(--pdp-gap-sm);
    font-size: 12.5px;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--pdp-ink-3);
}

.pdp__retailers-featured {
    display: flex;
    flex-wrap: wrap;
    gap: var(--pdp-gap-sm);
}

.pdp__retailer-tile {
    display: flex;
    align-items: center;
    justify-content: center;
    width: calc(33.333% - 7px);
    min-width: 96px;
    max-width: 180px;
    padding: 8px;
    border: 1px solid var(--pdp-line);
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-card);
    text-decoration: none;
    transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.pdp__retailer-tile:hover {
    border-color: var(--pdp-accent);
    box-shadow: var(--pdp-shadow);
}

.pdp__retailer-tile img {
    max-width: 100%;
    max-height: 56px;
    object-fit: contain;
}

.pdp__retailer-name {
    font-size: 12.5px;
    font-weight: 700;
    text-align: center;
    color: var(--pdp-ink);
}

.pdp__retailers-more {
    margin-top: var(--pdp-gap-sm);
}

.pdp__retailer-select {
    width: 100%;
    max-width: 300px;
    padding: 10px 2rem 10px 12px;
    border: 1.5px solid var(--pdp-line-2);
    border-radius: var(--pdp-radius-sm);
    font-size: 0.875rem;
    color: var(--pdp-ink-2);
    background: var(--pdp-card) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%234e4e58' viewBox='0 0 16 16'%3E%3Cpath d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E") no-repeat right 12px center;
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
    cursor: pointer;
}

.pdp__retailer-select:hover {
    border-color: var(--pdp-accent);
}

/* --- Expandable description --------------------------------------------- */

/*
 * Collapsed by JS only. The copy ships fully expanded so a crawler, a reader
 * with JS off and a printed page all get the whole thing; product-detail.js
 * adds --collapsed and unhides the toggle when (and only when) the block is
 * genuinely taller than the clamp. No height transition on purpose: animating
 * max-height needs a measured pixel value that a JS-off render can't supply.
 */
/*
 * Lives in the buy column now, so it is spaced like a sibling block of the buy
 * box (a section-sized 44px gap would read as a page break inside the column)
 * and takes the column's width rather than a 760px measure of its own.
 */
.pdp__info {
    padding-top: var(--pdp-gap);
    border-top: 1px solid var(--pdp-line);
}

.pdp__info-heading {
    margin: 0 0 var(--pdp-gap-sm);
    font-size: 12.5px;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--pdp-ink-3);
}

.pdp__info-body {
    position: relative;
    font-size: 14px;
    line-height: 1.7;
    color: var(--pdp-ink-2);
}

.pdp__info-body > :first-child {
    margin-top: 0;
}

.pdp__info-body > :last-child {
    margin-bottom: 0;
}

.pdp__info-body--collapsed {
    max-height: 19rem;
    overflow: hidden;
}

.pdp__info-body--collapsed::after {
    content: '';
    position: absolute;
    inset: auto 0 0;
    height: 5rem;
    background: linear-gradient(180deg, rgba(255, 255, 255, 0), var(--pdp-card));
    pointer-events: none;
}

.pdp__info-toggle {
    margin-top: var(--pdp-gap-sm);
    padding: 10px 16px;
    border: 1.5px solid var(--pdp-line-2);
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-card);
    color: var(--pdp-accent-ink);
    font-size: 13px;
    font-weight: 700;
    cursor: pointer;
}

.pdp__info-toggle[hidden] {
    display: none;
}

.pdp__info-toggle:hover {
    border-color: var(--pdp-accent);
}

/*
 * The `.pdp__additional` band that used to sit here is GONE, along with its
 * markup. `product_pages.description_additional_info` is rendered by the
 * Spotlights section now, under a single merged "Additional Info" heading — two
 * blocks reading the same field printed the copy twice.
 */

/* --- Registered PDP sections (shell only) ------------------------------- */

/*
 * Vertical rhythm lives in pdp-tokens.css so a new section inherits it. This
 * file only adds the rule above each band and the variant-scope contract.
 */
/* A section hosted in the buy column is a grid item, and a grid item's automatic
   minimum width is its content's — for a rail of seven cards that is the whole
   rail, so the column grew to 1020px and pushed the page sideways (Jesse,
   2026-09-09, on the instructional books page). Zero it, so the rail's own
   scroller/Swiper viewport does the clipping the way it does in the bottom stack. */
.pdp__section--in-column {
    min-width: 0;
}

.pdp__section {
    display: block;
    padding-top: var(--pdp-gap-lg);
    border-top: 1px solid var(--pdp-line);
}

/*
 * Variant-scoped blocks are hidden with the `hidden` attribute so assistive
 * tech and find-in-page agree with what's painted. The attribute selector beats
 * the `display: block` above on specificity, so no !important is needed — but
 * section partials must not re-declare `display` on .pdp__section itself.
 */
.pdp__section[hidden] {
    display: none;
}

/* A section hosted inside the details column (Similar products) keeps the
   column's rhythm instead of the full-width stack's. */
.pdp__section--in-column {
    padding-top: var(--pdp-gap);
    border-top: 1px solid var(--pdp-line);
}

/* --- Mobile sticky buy bar ---------------------------------------------- */

/*
 * position: sticky, NOT fixed. Sticky pins the bar to the bottom of the
 * viewport while the PDP is on screen and then lets it scroll away at the end
 * of the page wrapper, so it can never sit on top of the footer and needs no
 * body-padding hack in a layout file this task does not own. It is one button
 * in normal DOM order after the article, so there is nothing to focus-trap.
 */
/* Jesse, 2026-09-09: one buy bar for every viewport. Fixed to the bottom, off
   screen by default, slid up by product-detail.js once the price row's Add to
   Cart has scrolled past (.pdp-stickybar--shown). `hidden` = the active variant
   cannot be bought (unchanged contract). */
/*
 * Docks above the footer (Jesse, 2026-09-09). The bar is position: sticky at the
 * END of the main content with zero height: while the page continues below the
 * viewport it sits on the viewport's bottom edge like a fixed bar, and once the
 * footer scrolls up it stops at its own place in the flow, right above it. No
 * measuring, no scroll handler. Zero height means it reserves no space when
 * hidden, so the visible surface is an absolutely positioned layer growing
 * upward from that zero-height line.
 *
 * The layer itself runs edge to edge; its contents sit inside the same
 * .container the header and the page use, so they line up with the content
 * above rather than hugging the browser edge.
 */
.pdp-stickybar {
    position: sticky;
    bottom: 0;
    height: 0;
    z-index: 30;
}

.pdp-stickybar__bar {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    padding: 10px 0;
    border-top: 1px solid var(--pdp-line);
    background: var(--pdp-card);
    box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.08);
    transform: translateY(100%);
    visibility: hidden;
}

.pdp-stickybar__inner {
    display: flex;
    align-items: center;
    gap: 12px;
}

.pdp-stickybar--shown .pdp-stickybar__bar {
    transform: translateY(0);
    visibility: visible;
}

@media (prefers-reduced-motion: no-preference) {
    .pdp-stickybar__bar {
        transition: transform 0.28s cubic-bezier(0.2, 0.7, 0.2, 1), visibility 0s linear 0.28s;
    }

    .pdp-stickybar--shown .pdp-stickybar__bar {
        transition: transform 0.28s cubic-bezier(0.2, 0.7, 0.2, 1), visibility 0s;
    }
}

.pdp-stickybar[hidden] {
    display: none;
}

.pdp-stickybar__thumb {
    flex: 0 0 auto;
    width: 48px;
    height: 48px;
    border-radius: 8px;
    object-fit: contain;
    background: var(--pdp-bg-alt);
}

.pdp-stickybar__thumb[src=""] {
    visibility: hidden;
}

.pdp-stickybar__info {
    min-width: 0;
    flex: 1 1 auto;
}

.pdp-stickybar__price {
    display: block;
    font-size: 18px;
    font-weight: 800;
}

.pdp-stickybar__name {
    display: block;
    font-size: 12px;
    color: var(--pdp-ink-3);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.pdp-stickybar__packs {
    display: flex;
    flex: 0 0 auto;
}

.pdp-stickybar__packs[hidden],
.pdp-stickybar__packs:empty {
    display: none;
}

.pdp-stickybar__packs .pdp__pack-list {
    gap: 6px;
}

.pdp-stickybar__packs .pdp__pack-list > li {
    flex: 0 0 auto;
    min-width: 0;
}

.pdp-stickybar__packs .pdp__pack {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0 12px;
    min-height: 32px;
    border-radius: 999px;
    line-height: 1;
}

.pdp-stickybar__packs .pdp__pack-title {
    font-size: 12.5px;
    line-height: 1;
}

.pdp-stickybar__qty {
    display: inline-flex;
    align-items: stretch;
    overflow: hidden;
    border: 1.5px solid var(--pdp-line-2);
    border-radius: var(--pdp-radius-sm);
}

.pdp-stickybar__qty .pdp__qty-minus,
.pdp-stickybar__qty .pdp__qty-plus {
    width: 34px;
}

.pdp-stickybar__qty .pdp__qty-input {
    width: 44px;
}

.pdp-stickybar__buy {
    display: flex;
    align-items: center;
    gap: 12px;
    flex: 0 0 auto;
}

.pdp-stickybar__buy[hidden] {
    display: none;
}

/* A sold-out variant keeps the bar and says so where the button was. */
.pdp-stickybar__status {
    flex: 0 0 auto;
    padding: 9px 14px;
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-bg-alt);
    color: var(--pdp-ink-2);
    font-size: 13px;
    font-weight: 700;
}

.pdp-stickybar__status[hidden] {
    display: none;
}

.pdp-stickybar__btn {
    flex: 0 0 auto;
    padding: 11px 18px;
    border: 0;
    border-radius: var(--pdp-radius-sm);
    background: var(--pdp-accent-fill);
    color: var(--pdp-on-accent);
    font-size: 14px;
    font-weight: 700;
    cursor: pointer;
}

.pdp-stickybar__btn:hover {
    background: var(--pdp-accent-fill-hover);
}

@media (max-width: 640px) {
    .pdp-stickybar__packs,
    .pdp-stickybar__thumb {
        display: none;
    }

    .pdp-stickybar__btn {
        flex: 1;
    }
}


@media (max-width: 640px) {
    .pdp-page {
        padding: 0 14px;
    }

    /* Column count is set by the breakpoints beside the base rule, not here —
       an auto-fill override at this width would reintroduce the per-page
       column drift the fixed count exists to remove. */

    .pdp__price--current,
    .pdp__price--sale {
        font-size: 26px;
    }
}

/* --- Lightbox ------------------------------------------------------------ */

/*
 * The enlarged-image dialog.
 *
 * A NATIVE <dialog> opened with showModal(), which is what makes this ~70 lines
 * of CSS and no JS framework: the browser supplies the focus trap, Escape, the
 * inert background and — the part that matters structurally here — promotion to
 * the TOP LAYER. `.pdp__gallery` is position:sticky and `.pdp__stage` is
 * overflow:hidden, and a hand-rolled position:fixed overlay nested inside those
 * two is exactly the thing that ends up clipped or offset.
 *
 * Sized to the viewport rather than to the image, so the frame does not jump
 * between a portrait and a square view.
 *
 * WIDENED FROM min(92vw, 860px), AND THAT WAS HALF THE "ZOOM ISN'T BIGGER" BUG.
 * Arithmetic, not taste: at 860px the image's own column was 860 - 36 (inner
 * padding) - 80 (two 40px arrows) - 20 (two 10px gaps) = 724px, and the stage's
 * image maxes out at 614 CSS px (--pdp-maxw 1180, less 36px gutter, less the
 * 440px buy column, less the 38px gap, less the stage's 26px padding each side).
 * A 1.18x frame is not a zoom. At 1280 the image column is 1144px.
 *
 * The other half was the HEIGHT budget below — on a laptop-height viewport the
 * square image was height-bound at `92vh - 150px`, which on an 830px viewport is
 * 614px: the stage's size to the pixel. Whatever the source resolution, the frame
 * could not be bigger than what it was enlarging.
 */
.pdp__lightbox {
    width: min(96vw, 1280px);
    max-width: 96vw;
    max-height: 96vh;
    padding: 0;
    border: 0;
    border-radius: var(--pdp-radius);
    background: var(--pdp-bg);
    color: var(--pdp-ink);
    box-shadow: var(--pdp-shadow);
}

.pdp__lightbox::backdrop {
    background: rgba(22, 22, 25, 0.72);
}

.pdp__lightbox-inner {
    position: relative;
    display: grid;
    gap: var(--pdp-gap-sm);
    padding: 18px;
}

.pdp__lightbox-title {
    /* padding-right clears the close button, which is absolutely positioned so
       that a long product name wraps under it rather than shoving it around. */
    margin: 0;
    padding-right: 46px;
    font-size: 14px;
    font-weight: 700;
    line-height: 1.3;
    color: var(--pdp-ink);
}

.pdp__lightbox-close {
    position: absolute;
    top: 12px;
    right: 12px;
    z-index: 2;
    display: grid;
    place-items: center;
    width: 34px;
    height: 34px;
    padding: 0;
    border: 1px solid var(--pdp-line-2);
    border-radius: 50%;
    background: var(--pdp-bg);
    color: var(--pdp-ink);
    cursor: pointer;
    transition: background-color 0.15s ease, border-color 0.15s ease;
}

.pdp__lightbox-close:hover {
    border-color: var(--pdp-accent);
    background: var(--pdp-bg-alt);
}

/* An X, drawn — two rules crossing, so no glyph metrics to shift on state. */
.pdp__lightbox-close-icon {
    position: relative;
    width: 14px;
    height: 14px;
}

.pdp__lightbox-close-icon::before,
.pdp__lightbox-close-icon::after {
    content: "";
    position: absolute;
    top: 6px;
    left: 0;
    width: 14px;
    height: 2px;
    background: currentColor;
}

.pdp__lightbox-close-icon::before {
    transform: rotate(45deg);
}

.pdp__lightbox-close-icon::after {
    transform: rotate(-45deg);
}

/*
 * The arrows flank the image rather than float over it: inside a dialog there is
 * room to put them beside the thing they act on, and a control that never covers
 * the picture is one less reason to move it.
 *
 * `position: relative` is here only so the loading indicator can center itself on
 * the picture. It changes no metric: a grid container is already a containing
 * block for nothing, and the indicator is absolutely positioned, so it is not a
 * grid item and takes no column.
 *
 * THIS COMMENT WAS CLOSED TWICE AND THAT IS WHY THE ARROWS STACKED.
 *
 * A second, stray comment terminator sat six lines above
 * `.pdp__lightbox-figure`, ending the comment early. Everything after it — the
 * prose about `position: relative`, plus the real terminator — became live
 * stylesheet text. A lone asterisk is a valid selector, so the parser read that
 * text as the start of a qualified rule and, per CSS error recovery, kept
 * consuming the prelude until the NEXT open brace. That brace was this rule's. So
 * the declarations below were parsed as the block of an invalid selector and
 * dropped whole: on desktop `.pdp__lightbox-figure` had no `display: grid` and no
 * columns at all, and its children fell back to block flow — prev button, then
 * image, then next button, each on its own line. Jesse saw chevrons above and
 * below the picture.
 *
 * Two further symptoms had the same single cause: the lost `position: relative`
 * left the dialog's loading indicator centering on `.pdp__lightbox-inner` (the
 * next positioned ancestor) rather than on the picture, and the 480px block at
 * the end of this file — which survived intact — was setting `grid-row` and
 * `justify-self` on children of a container that was not a grid, so it did
 * nothing at all. The phone layout only LOOKED right because block flow already
 * stacks.
 *
 * PdpGalleryControlsTest now asserts that this file contains no comment
 * terminator outside a comment, so this class of silent rule-swallowing cannot
 * come back unnoticed.
 */
.pdp__lightbox-figure {
    position: relative;
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: center;
    gap: var(--pdp-gap-sm);
    min-height: 0;
}

/*
 * Same indicator as the stage's, over the dialog's picture — and it matters more
 * here, because this frame is fetching the 1600px transform (~1.8 MB measured)
 * rather than the 600px one the stage already has in cache.
 *
 * Centered with negative margins for the same reason as the stage's: the spin is a
 * transform and would otherwise throw a centring translate away. The spin keyframes
 * and the reduced-motion opt-out are shared with .pdp__stage-spinner above.
 */
.pdp__lightbox-spinner {
    position: absolute;
    top: 50%;
    left: 50%;
    z-index: 2;
    width: 34px;
    height: 34px;
    margin: -17px 0 0 -17px;
    border: 3px solid rgba(22, 22, 25, 0.16);
    border-top-color: var(--pdp-accent);
    border-radius: 50%;
    pointer-events: none;
}

.pdp__lightbox-spinner[hidden] {
    display: none;
}

.pdp__lightbox-nav {
    display: grid;
    place-items: center;
    width: 40px;
    height: 40px;
    padding: 0;
    border: 1px solid var(--pdp-line-2);
    border-radius: 50%;
    background: var(--pdp-bg);
    color: var(--pdp-ink);
    cursor: pointer;
    transition: background-color 0.15s ease, border-color 0.15s ease;
}

.pdp__lightbox-nav:hover {
    border-color: var(--pdp-accent);
    background: var(--pdp-bg-alt);
}

/* `hidden` needs saying out loud against `display: grid` above. */
.pdp__lightbox-nav[hidden],
.pdp__lightbox-count[hidden] {
    display: none;
}

/*
 * PLACED EXPLICITLY, NOT AUTO-PLACED. This is defense, not the fix — the stacked
 * arrows were the swallowed rule above, and this would not have saved them.
 *
 * What it does buy: auto-placement puts items in source order, so the layout was
 * only ever correct because the figure happens to contain exactly prev, image,
 * next in that order plus one absolutely positioned indicator. A fifth child, or
 * a reordered one, or an indicator that stopped being `position: absolute` would
 * silently push a chevron onto a second row. Naming the cell for each of the
 * three real children makes that impossible: a new child can only ever land in an
 * implicit row of its own, where it is obvious, rather than displacing an arrow.
 *
 * The row is named as well as the column. Without it a stray auto-placed sibling
 * could still claim row 1 and shove these down.
 */
.pdp__lightbox-nav--prev {
    grid-column: 1;
    grid-row: 1;
}

.pdp__lightbox-nav--next {
    grid-column: 3;
    grid-row: 1;
}

/* --- Lightbox: the frame, its reserved size, and the blank slot ---------- */

/*
 * THE FRAME IS WHAT GIVES THE DIALOG A SIZE BEFORE THE PICTURE ARRIVES.
 *
 * Jesse, round four: "When I hit zoom initially the lightbox is just a bar on
 * the screen till the image loads." The dialog's WIDTH was never the problem —
 * `.pdp__lightbox` authors `min(96vw, 1280px)`. Its HEIGHT was coming entirely
 * from content, and the content was an <img> with no authored width or height
 * (see .pdp__lightbox-image below for why that is right) and therefore no
 * intrinsic size at all until 0.8-2.8 MB of PNG had decoded. So the dialog
 * opened as the title line, the close button and the counter: a bar.
 *
 * This element reserves the picture's box from CSS alone, so the dialog is its
 * final size in the frame it opens in, and NOTHING MOVES when the photo lands.
 *
 * SQUARE, AND THAT IS MEASURED RATHER THAN COPIED FROM THE STAGE. galleryUrl()
 * asks for `fit: contain`, which preserves the source ratio, so a non-square
 * source would come back non-square. Nine real gallery assets were probed at
 * the zoom size — three `product-images`, one `product-images-back`, and five
 * `product-images-gallery` (the legacy product_photos ports, the likeliest to be
 * odd) — and every one is 2000x2000 at source and exactly 1600x1600 through the
 * handler. The SOURCES are square; the service is not padding them. A square
 * slot is therefore the true size and not an approximation — and if a
 * non-square asset ever appears it letterboxes inside this slot instead of
 * resizing it, because the <img> below is capped in both axes and centered.
 *
 * THE CAP IS ON THE WIDTH AXIS, WHICH LOOKS WRONG AND IS NOT. The height budget
 * is unchanged — 96vh less 118px of dialog chrome, the number the arithmetic
 * below the old rule worked out — but on a SQUARE box height follows width, so
 * capping the width is what caps the height. `max-height` here would be the bug:
 * with a definite width, clamping the height would make the box non-square and
 * put the slot back to a shape the photo does not fill.
 *
 * THE HEIGHT ALLOWANCE IS MEASURED, NOT ROUNDED UP. The dialog's chrome is 36px
 * of inner padding + an 18px title line + two 10px gaps + a 14px counter = 88px.
 * 118 leaves room for a title that wraps to two lines and nothing more; the old
 * 150 was giving away 32px of picture on every laptop, where the square image is
 * height-bound rather than width-bound.
 *
 * IT IS THE GRID ITEM NOW. `.pdp__lightbox-figure` names a cell on each of its
 * three real children explicitly, and wrapping the image made this element the
 * child — so `grid-column`/`grid-row` moved here from `.pdp__lightbox-image`, at
 * BOTH breakpoints (the 480px block at the end of this file has its own
 * assignments and they moved too). Chevrons above and below the picture instead
 * of either side of it is a defect Jesse has already reported once; putting
 * either assignment back on the <img> would reproduce it, because the <img> is
 * no longer a grid item and the frame would fall to auto-placement.
 *
 * `display: grid; place-items: center` is how the photo sits in the middle of
 * the slot in both axes, replacing the `margin: 0 auto` the <img> used to carry
 * for one axis. `min-height: 0` turns off a grid item's automatic content-based
 * minimum, so nothing inside can push the reserved box taller than the ratio
 * says. No `position: relative`: nothing is positioned against this element —
 * the loading indicator centers on the figure, deliberately.
 */
.pdp__lightbox-frame {
    /* The middle cell, named rather than auto-placed — see .pdp__lightbox-nav--prev. */
    grid-column: 2;
    grid-row: 1;
    display: grid;
    place-items: center;
    width: min(100%, calc(96vh - 118px));
    min-height: 0;
    aspect-ratio: 1 / 1;
    margin: 0 auto;
}

/*
 * THE BLANK SLOT, IN THE DIALOG — same defect as the stage's, same mechanism,
 * same vocabulary.
 *
 * Jesse: "Yeah do the same for the lightbox." Stepping with the dialog's arrows
 * or the arrow keys held last time's photo until the new one decoded, exactly as
 * the stage used to, and here the wait is far longer: the stage re-fetches a
 * 600px file it usually has in cache, while this is a 0.8-2.8 MB transform that
 * has almost never been requested before. So the slot is not a formality here —
 * it and the spinner are what a shopper actually looks at most times.
 *
 * VISIBILITY, NOT A CLEARED src, for the reason .pdp__stage--loading gives at
 * length: an emptied or placeholder src fires the element's `error` handler, and
 * in this dialog that handler owns the one-shot downgrade to the stage's cached
 * 600px source. A deliberate blank that looked like a failure would spend that
 * downgrade on an image loading perfectly well. The src is always a real
 * intended URL, so `error` keeps exactly one meaning and no sentinel exists.
 *
 * SOLID AND FLAT, and the same fill and ring as the stage's slot — one visual
 * language for "the picture is coming", wherever it is coming.
 *
 * The ring is an inset box-shadow rather than a border for the metric reason
 * this file records elsewhere: a border would add 2px to a box, and this one has
 * a reserved size that must not change when the photo lands.
 */
/*
 * Transparent for the same reason as the stage slot above, and changed with it
 * so the two never diverge — one plated and one bare would read as a bug. Both
 * spinners come from the same makeSpinner() and share SPINNER_DELAY_MS, so this
 * state has the same fast-load gap the stage does: nothing painted for the
 * first 150ms.
 */
.pdp__lightbox--loading .pdp__lightbox-frame {
    background: transparent;
    border-radius: 0;
    box-shadow: none;
}

.pdp__lightbox--loading .pdp__lightbox-frame img {
    visibility: hidden;
}

/*
 * THE SOURCE IS LARGER THAN THE FRAME, WHICH IS WHAT MAKES THIS A ZOOM.
 *
 * The note that used to sit here said this deliberately does not upscale, and
 * that the fix was "a LARGER SOURCE from the server, not more CSS". The larger
 * source now exists (CatalogController::GALLERY_ZOOM_SIZE, 1600px), so the two
 * caps below have stopped being a ceiling and become a FIT: a 1600px image is
 * downscaled into the frame rather than stretched to it.
 *
 * STILL NO `width` OR `height`, AND THAT IS NOT AN OVERSIGHT — it is also the
 * reason the dialog used to open as a bar, and the frame above is the answer to
 * that rather than authoring a size here. `max-width`/`max-height` alone mean
 * the image renders at whichever is smaller — the slot, or its own intrinsic
 * size — so art that is genuinely smaller than 1600px (the CDN's
 * `withoutEnlargement` returns the intrinsic size for it) is shown at its real
 * resolution instead of being blown up into blur and called a zoom. A
 * `width: 100%` here would trade a real bug for a cosmetic one.
 *
 * THE CAPS ARE NOW 100% OF THE SLOT rather than the viewport arithmetic, because
 * the slot is already that arithmetic — see .pdp__lightbox-frame. The rendered
 * size of a square source is identical to before, to the pixel; what changed is
 * that the number is written once instead of twice.
 *
 * `object-fit: contain` is belt, not the mechanism: with no authored dimensions
 * the box already takes the intrinsic ratio, so this never engages. It is what
 * would keep a non-square asset undistorted if the caps were ever paired with an
 * authored size.
 *
 * No `grid-column`/`grid-row`: this is NOT a grid item any more. The frame is.
 */
.pdp__lightbox-image {
    display: block;
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
}

.pdp__lightbox-count {
    margin: 0;
    text-align: center;
    font-size: 12px; /* was 11px; 12px floor — WAVE "very small text" (2026-09-22) */
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--pdp-ink-3);
}

/*
 * Background scroll lock — the one modal behaviour showModal() does NOT provide.
 * `overflow: hidden` on the body keeps the current scroll offset, so the page is
 * where it was when the dialog closes.
 *
 * Known cosmetic nit on platforms with classic (space-taking) scrollbars: the
 * bar disappears while the dialog is open and the page behind it shifts by its
 * width. Reserving the gutter up front would mean a `scrollbar-gutter` rule on
 * the root for every PDP view, which is a bigger change than the flicker.
 */
body.pdp-lightbox-open {
    overflow: hidden;
}

/*
 * A short fade for the dialog and its backdrop, and NOTHING for anyone who has
 * asked for less motion — the reduce block below is what they get instead of a
 * shorter version of the same animation.
 */
@media (prefers-reduced-motion: no-preference) {
    .pdp__lightbox[open],
    .pdp__lightbox[open]::backdrop {
        animation: pdp-lightbox-in 0.16s ease-out;
    }
}

@keyframes pdp-lightbox-in {
    from { opacity: 0; }
    to { opacity: 1; }
}

/*
 * Every transition introduced by the gallery controls, switched off. The color
 * changes still happen — they are state, not decoration — they just happen at
 * once.
 */
@media (prefers-reduced-motion: reduce) {
    .pdp__stage-nav-btn,
    .pdp__stage-zoom,
    .pdp__lightbox-nav,
    .pdp__lightbox-close {
        transition: none;
    }
}

/*
 * Phones: the stage is small enough that flanking arrows inside the dialog would
 * squeeze the picture, so they move under it and the image gets the full width.
 */
@media (max-width: 480px) {
    /*
     * TWO COLUMNS, NOT ONE, and the image spans both.
     *
     * This block never actually ran until the swallowed base rule above was
     * repaired, so it is being exercised as a grid for the first time. With the
     * single `1fr` it used to declare, the two arrows both asked for row 2 with no
     * column, and auto-placement would have put the second one in an IMPLICIT
     * second column — a track the author never declared, sized by its content,
     * which the image in row 1 did not span. Declaring the second column and
     * spanning the image across it is the same layout with nothing implicit in it.
     *
     * Every column and row here is named for the same reason the desktop rules
     * are: at this width a mis-placed arrow lands on top of the picture.
     */
    .pdp__lightbox-figure {
        grid-template-columns: 1fr 1fr;
        justify-items: center;
    }

    .pdp__lightbox-nav--prev {
        grid-column: 1;
        grid-row: 2;
        justify-self: start;
    }

    .pdp__lightbox-nav--next {
        grid-column: 2;
        grid-row: 2;
        justify-self: end;
    }

    /*
     * The arrows have moved to a row of their own, so the height budget shrinks by
     * their 40px plus the 10px gap, on top of the 118px the desktop rule reserves.
     *
     * ON THE FRAME, NOT THE <img> — and this is the half of the trap that is easy
     * to miss. This block has its own explicit cell assignments, and bb58c58
     * established that it had never actually run as a grid before (the base rule
     * was being swallowed and block flow happened to look right). Wrapping the
     * image made the FRAME the grid item, so `grid-column: 1 / -1` and
     * `grid-row: 1` have to be here rather than on the picture, or the frame
     * auto-places into column 1 of row 1 and the prev arrow — which asks for
     * column 1 of row 2 — no longer sits under a full-width picture.
     *
     * The cap is on the width axis for the same reason as the desktop rule: the
     * slot is square, so width is what bounds the height.
     */
    .pdp__lightbox-frame {
        grid-column: 1 / -1;
        grid-row: 1;
        width: min(100%, calc(96vh - 168px));
    }

    /*
     * The figure is now two rows tall, so its 50% mark sits below the picture.
     * Lifted by half the arrow row (40px + the 10px gap) to land back on the
     * center of the image, which is the thing the indicator is about.
     */
    .pdp__lightbox-spinner {
        top: calc(50% - 25px);
    }
}

/* A serial's gallery note: which photos are the guitar being bought
   (docs/features/mm-shop.md § Photographed units). */
.pdp__gallery-note {
    margin: 0.5rem 0 0;
    font-size: 0.875rem;
    line-height: 1.4;
}

/* The stock-photo notice (product_pages.stock_photo_notice), bottom LEFT of
   the stage and the lightbox photo (bottom right is the zoom button: 40px at
   12px in, so the note stops 64px short of that edge), only while the photo
   shown is a stock one (.pdp__gallery--stock, kept by product-detail.js). 12px
   is the smallest we set body-weight text; white on 78% black clears 4.5:1
   over any photo. */
.pdp__stock-note {
    display: none;
    position: absolute;
    left: 6px;
    bottom: 6px;
    max-width: calc(100% - 70px);
    padding: 2px 6px;
    border-radius: 3px;
    background: rgba(0, 0, 0, 0.78);
    color: #fff;
    font-size: 0.75rem;
    line-height: 1.35;
    pointer-events: none;
}
.pdp__gallery--stock .pdp__stock-note,
.pdp__lightbox--stock .pdp__stock-note { display: block; }
.pdp__lightbox-frame { position: relative; }

/* California Proposition 65 warning, last on every instrument page. */
.pdp__prop65 {
    margin: 2rem 0 0;
    font-size: 0.875rem;
    line-height: 1.5;
}
.pdp__prop65-symbol {
    vertical-align: -2px;
    margin-right: 2px;
}

/* An instrument held in another customer's cart (docs/features/instrument-reservations.md):
   the swatch tag in the New tag's place, and the status line muted like Sold. */
.pdp__variant-reserved {
    position: absolute;
    top: 4px;
    left: 4px;
    z-index: 1;
    padding: 2px 6px;
    border-radius: 5px;
    background: #4a4a4a;
    color: #fff;
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    line-height: 1.3;
}

/* One instrument line per variant on a page of serials: `display: grid` above
   would otherwise outrank the `hidden` the variant switch toggles. */
.pdp__instrument-avail[hidden] { display: none; }
