﻿/* =========================================
   Buttons
   ========================================= */
/* From button-styles.css */
/* A button's icon is a flex item and shrinks by default. In a narrow column
   (the /stars card actions at 390px) the label won an "Продукти (6)" tug-of-war
   and the 14px glyph collapsed to width:0 while keeping its height — reading as
   a "tiny" icon rather than a missing one. Icons never shrink. */
.btn > svg,
.btn-primary > svg,
.btn-primary-pale > svg,
.btn-green > svg,
.btn-green-pale > svg,
.btn-blue > svg,
.btn-blue-pale > svg,
.btn-red-pale > svg,
.btn-grey > svg,
.btn-neutral > svg { flex-shrink: 0; }

/* Base Button Structure - Shared across all button types */
.btn,
.btn-primary,
.btn-primary-pale,
.btn-green,
.btn-green-pale,
.btn-blue,
.btn-blue-pale,
.btn-red-pale,
.btn-grey,
.btn-neutral {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    /* min-height, not height, so the box can grow with its own label. Several
       settings scale text without scaling lengths (Chrome for Android's Text
       scaling slider, a browser font-size preference); a pinned height with zero
       vertical padding has no slack for that and the label spills past the border
       (measured at x1.3: 100 buttons on the homepage). The em padding grows with the label
       and --control-height becomes the floor, so buttons still sit level with
       inputs/selects/.search-field at normal text size. */
    min-height: var(--control-height);
    padding: 0.3em 24px;
    /* 6px = the form-control radius (2026-09-24, owner): buttons now match the
       inputs/selects they sit beside, and .btn-sm, which already used it. */
    border-radius: var(--radius-control);
    font-family: inherit;
    font-weight: 500;
    font-size: var(--fs-md);
    cursor: pointer;
    text-decoration: none;
    transition: all 0.2s ease;
    /* Height + flex centring own the vertical rhythm now. line-height:normal
       lets the font's own (symmetric) leading sit the glyphs dead-centre — the
       old 1.7 added extra leading that pushed the label up, and a flat 1 makes
       the strut tighter than the glyph so it can still drift. */
    line-height: normal;
    /* The horizontal half of the same guard: with the height a floor, a 2nd line
       grows the button instead of spilling past it, so wrapping is now the safe
       default. nowrap turned "the label got bigger" into "the label left the box"
       with nothing able to give. Tight rows still wrap whole buttons (.flex-wrap). */
    white-space: normal;
    box-sizing: border-box;
    border: 1px solid transparent;
    /* Default border style */
}

/* Default .btn Appearance (Outline Pink) */
.btn {
    background-color: transparent;
    color: var(--pink-highlight);
    border-color: var(--pink-highlight);
}

.btn:active,
.btn:focus-visible {
    background-color: var(--pink-highlight-pale);
    color: var(--pink-highlight);   /* hold the brand pink — otherwise the global a:hover (core.css) wins on <a class="btn"> and turns it #2451b8 blue */
    text-decoration: none;
}
/* Hover only on real pointers — on touch :hover latches after a tap and leaves
   the control looking pressed. :active / :focus-visible above stay ungated. */
@media (hover: hover) {
    .btn:hover {
        background-color: var(--pink-highlight-pale);
        color: var(--pink-highlight);
        text-decoration: none;
    }
}

.btn:disabled,
.btn-primary:disabled,
.btn-primary-pale:disabled,
.btn-green:disabled,
.btn-green-pale:disabled,
.btn-blue:disabled,
.btn-blue-pale:disabled,
.btn-red-pale:disabled,
.btn-grey:disabled,
.btn-neutral:disabled {
    opacity: 0.65;
    cursor: not-allowed;
    background-color: var(--grey-highlight-pale);
    color: var(--grey-highlight);
    border-color: var(--grey-highlight);
}

/* Opt-in: keep a primary CTA in its brand colour when disabled (just dimmed),
   instead of the default grey. For prominent "submit when valid" buttons —
   e.g. the search hero — where a grey disabled state reads as broken. */
.btn-primary.btn-keep-color:disabled {
    opacity: 0.55;
    cursor: not-allowed;
    background-color: var(--btn-primary-bg);
    border-color: var(--btn-primary-bg);
    color: var(--btn-primary-text);
}

/* Primary (Pink Filled) */
.btn-primary {
    background-color: var(--pink-highlight);
    color: #fff;
    border: 1px solid var(--pink-highlight);
}

@media (hover: hover) {
    .btn-primary:hover {
        background-color: var(--btn-primary-hover);
        border-color: var(--btn-primary-hover);
        color: #fff;
    }
}

/* Primary Pale (Pink Pale) */
.btn-primary-pale {
    background-color: var(--pink-highlight-pale);
    color: var(--pink-highlight);
    border: 1px solid transparent;
}

@media (hover: hover) {
    .btn-primary-pale:hover {
        background-color: #f8e1ea;
        border-color: #f8e1ea;
        color: var(--pink-highlight);   /* hold brand pink — otherwise the global a:hover (core.css) wins on <a class="btn-primary-pale"> and turns it #2451b8 blue */
    }
}

/* Green (Filled) — the fill is --green-highlight-dark, not --green-highlight:
   white on the base green is 2.91:1, a plain AA fail for a button label.
   #218838 carries white at 4.52:1, and the hover step moves down one more. */
.btn-green {
    background-color: var(--green-highlight-dark);
    color: #fff;
    border: 1px solid var(--green-highlight-dark);
}

@media (hover: hover) {
    .btn-green:hover {
        background-color: var(--green-highlight-darker);
        border-color: var(--green-highlight-darker);
        color: #fff;
    }
}

/* Green Pale */
.btn-green-pale {
    background-color: var(--green-highlight-pale);
    color: var(--green-highlight-text);
    border: 1px solid transparent;
}

@media (hover: hover) {
    .btn-green-pale:hover {
        background-color: var(--green-highlight-pale-hover);
        color: var(--green-highlight-pale-hover-text);
    }
}

/* Red pale — destructive verdicts that share the pale-button look. Used by
   the admin Identity Reviews Accept button when reason='gtin_outlier' (Accept
   means "yes this is bad data" → destructive). */
.btn-red-pale {
    background-color: #fde8e8;
    color: #c0392b;
    border: 1px solid transparent;
}
@media (hover: hover) {
    .btn-red-pale:hover {
        background-color: #fbd5d5;
        color: #a52a1f;
    }
}

/* Blue (Filled) */
.btn-blue {
    background-color: var(--blue-highlight);
    color: #fff;
    border: 1px solid var(--blue-highlight);
}

@media (hover: hover) {
    .btn-blue:hover {
        background-color: var(--blue-highlight-dark);
        color: #fff;
    }
}

/* Blue Pale */
.btn-blue-pale {
    background-color: var(--blue-highlight-pale);
    color: var(--blue-highlight);
    border: 1px solid transparent;
}

@media (hover: hover) {
    .btn-blue-pale:hover {
        background-color: var(--blue-highlight-pale-hover);
        color: var(--blue-highlight);
    }
}

/* Grey (Filled) */
.btn-grey {
    background-color: var(--grey-highlight);
    color: #fff;
    border: 1px solid var(--grey-highlight);
}

@media (hover: hover) {
    .btn-grey:hover {
        background-color: #5a6268;
        color: #fff;
    }
}

/* Neutral outline — white field, token border, dark label. The quiet,
   non-branded button (e.g. social sign-in). */
.btn-neutral {
    background-color: var(--surface);
    color: var(--text-color);
    border-color: var(--border-color);
}

@media (hover: hover) {
    .btn-neutral:hover {
        background-color: var(--bg-light);
        color: var(--text-color);
    }
}

/* Anchor buttons keep their variant colour across every link state.
   A button rendered as <a href> (buy links, CTAs, CMS/Quill "page buttons")
   is otherwise repainted once clicked: the UA `a:visited` (purple) and the
   global `a` / `a:hover` rules in core.css beat the `.btn-*` colour on
   specificity/order, turning the label blue/purple. `:visited` may only change
   colour/bg/border, so a higher-specificity author colour wins in every state.
   We re-assert each variant's own colour on :link/:visited and exclude :hover
   so the per-variant hover shades above still apply (no hover regression, no
   new classes — grouped by colour for reuse). */
a.btn:is(:link, :visited):not(:hover),
a.btn-primary-pale:is(:link, :visited):not(:hover) { color: var(--pink-highlight); }

a.btn-primary:is(:link, :visited):not(:hover),
a.btn-green:is(:link, :visited):not(:hover),
a.btn-blue:is(:link, :visited):not(:hover),
a.btn-grey:is(:link, :visited):not(:hover) { color: #fff; }

a.btn-green-pale:is(:link, :visited):not(:hover) { color: var(--green-highlight-text); }
a.btn-blue-pale:is(:link, :visited):not(:hover) { color: var(--blue-highlight); }
a.btn-red-pale:is(:link, :visited):not(:hover) { color: #c0392b; }
a.btn-neutral:is(:link, :visited):not(:hover) { color: var(--text-color); }

/* Skip to content — the first focusable element on every page carrying the
   header, so keyboard and switch users can jump the nav instead of tabbing it
   on every navigation (WCAG 2.4.1). Positioning only: it rides .btn-primary for
   colour, padding, radius and type, and the a.btn-primary rule above already
   keeps that colour across :visited.

   Moved off-screen rather than display:none / visibility:hidden — either would
   drop it from the tab order, which is the one thing it exists to be in. It
   sits above .app-header's 1020 but below the modal ladder (2000+), which it
   never competes with: nothing can be scrolled to while a modal owns focus. */
.skip-link {
    position: absolute;
    top: 0;
    left: 0;
    z-index: 1030;
    transform: translateY(-200%);
}

.skip-link:focus {
    transform: translateY(0);
}

/* The target carries tabindex="-1" so activating the link actually moves
   keyboard focus — a bare id only scrolls, leaving the next Tab back up in the
   nav the user just skipped. Being tabindex="-1" it is not tab-reachable, so
   2.4.7 asks for no indicator on it, while the global [tabindex]:focus-visible
   ring would outline the whole page region and say nothing. Beats that rule on
   specificity (0,2,1 vs 0,2,0), so no !important. */
main[tabindex="-1"]:focus {
    outline: none;
}

/* Chrome-stripped text button — a <button> that reads as a link. Replaces four
   separate implementations of the same core (.notif-mark-all-btn,
   .multi-select-action, .filter-reset-btn, and an inline style in
   TrackPriceContent). Deliberately NOT in the filled .btn-* grouped selector
   above: it shares none of their height or radius. font-family arrives from the
   global button rule in core.css.
   The core is the flush, undecorated form — that is what three of the four were.
   Underline and hit-area are real axes, not drift, so they are modifiers below. */
.btn-link {
    background: none;
    border: none;
    padding: 0;
    font-size: var(--fs-base);
    color: var(--pink-highlight);
    cursor: pointer;
}
.btn-link:hover { color: var(--btn-primary-hover); }

/* Reads as prose: an action sitting in running text or a footer row, where the
   underline is what marks it as clickable at all. */
.btn-link-underline { text-decoration: underline; text-underline-offset: 2px; }

/* Reads as a control: a discrete target in a toolbar or filter bar, where the
   label alone is the affordance and the hover paints a box instead of shifting
   the text colour. Padding + radius + background travel together — the pale fill
   only registers as a target if there is a box to fill — so this is one modifier
   rather than three. Also what keeps "Clear all" a usable thumb target on touch. */
.btn-link-soft {
    padding: 2px 6px;
    border-radius: var(--radius-sm);
    transition: background 0.15s;
}
.btn-link-soft:hover { background: var(--pink-highlight-pale); color: var(--pink-highlight); }

/* Compact size modifier — pairs with any colour above (.btn .btn-sm, …). Same
   pinned-height + line-height:normal model as the base button, just shorter, so
   toolbar rows (Docs/Apply/Edit-Cancel) stay pixel-even. Lives here, not in a
   page sheet, so it works on every page. */
.btn-sm {
    min-height: var(--control-height-sm);
    padding: 0.3em 12px;
    font-size: var(--fs-sm);
    line-height: normal;
    border-radius: var(--radius-control);
}

/* Medium height modifier — the API for the 32px tier (--control-height-md:
   header chrome / toggles). ONLY drops the height; base padding / font / radius
   are kept (unlike .btn-sm, which also tightens). */
.btn-md {
    min-height: var(--control-height-md);
}

/* Narrow modifier — KEEPS the full control height but tightens the horizontal
   footprint (less side padding, smaller gap + label) so a full-size button fits
   a dense row/cell without shrinking its height like .btn-sm does.
   Pairs with any colour (.btn-green .btn-narrow, …).
   No white-space here: it used to re-pin nowrap, which cancelled the base
   button's wrap-instead-of-spill guard for exactly the dense cells that have
   the least room to spare — "Продукти (129)" at 1100px was clipping on that. */
.btn-narrow {
    gap: 4px;
    padding: 0.3em 14px;
    font-size: var(--fs-sm);
}

/* Block modifier — full-width button (fills its container). Pairs with any
   colour; the base button's flex centring keeps the label centred. Replaces a
   pile of per-button `width:100%` hand-rolls. */
.btn-block { width: 100%; }

/* Split button — two standard buttons side by side, reading as TWO buttons (a
   6px gap, each keeps its own full radius — owner, 2026-09-24): a MAIN part
   folded to an icon square, and a SIDE part filling the rest. The order is the
   markup's (ProductCard, owner 09-25: pink "Save to list" left, blue cart
   square right). Opening the main
   part — mouse hover or keyboard focus on it, or .is-main-open (set by JS on a
   first touch tap) — swaps them: the main part grows to show its label and the
   side part folds to its icon. The swap animates flex-grow, so the pair's
   total width never changes and the card around it never moves. Each part keeps
   its own colour class; labels stay in the DOM (aria-label on each part too). */
.btn-split {
    display: flex;
    gap: 6px;
    width: 100%;
    min-width: 0;
    /* One curve + one duration for the whole swap, so the two parts and the
       labels move as one gesture. 0.4s ease-in-out-ish (owner: "smoother and
       slightly longer" than the first 0.25s ease). */
    --split-t: 0.4s;
    --split-ease: cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-split > * {
    flex: 1 1 0;
    min-width: 0;
    padding: 0.3em 10px;
    gap: 6px;
    white-space: nowrap;
    overflow: hidden;
    transition: flex var(--split-t) var(--split-ease),
                padding var(--split-t) var(--split-ease),
                gap var(--split-t) var(--split-ease),
                background-color 0.2s ease;
}
/* Labels never leave the layout (display:none can't animate): they collapse to
   zero width and fade. The OUTGOING label fades fast, so it is gone before its
   part gets narrow; the INCOMING one waits a beat, so it fades in once its part
   has room instead of being revealed letter by letter. */
.btn-split-label {
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 14em;
    opacity: 1;
    transition: max-width var(--split-t) var(--split-ease),
                opacity 0.25s var(--split-ease) 0.12s;
}
/* Phones: a product card's split is ~160px, which leaves the open part ~115px
   — "Добави в списък" ellipsised to "Добави в …". There it wraps to two tight
   lines instead, which still sits inside the 44px touch-height button. */
@media (max-width: 600px) {
    .btn-split > * { white-space: normal; }
    .btn-split-label {
        font-size: var(--fs-sm);
        line-height: 1.1;
        text-align: left;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
    }
}
/* A part may carry two labels, long + short: the short one is for phones
   (ProductCard: "Save to List" → "Save"). Same 600px boundary as the wrap
   rule above, not .desktop-only/.mobile-only (core.css, 1100px). */
.btn-split-label-short { display: none; }
@media (max-width: 600px) {
    .btn-split-label-long { display: none; }
    .btn-split-label-short { display: -webkit-box; }
}
/* FOLDED part: an icon square the size of the control height, label gone.
   At rest that is the main part; once open, the side part. */
.btn-split > .btn-split-main,
.btn-split.is-main-open > .btn-split-side,
.btn-split:has(> .btn-split-main:focus-visible) > .btn-split-side {
    flex: 0 0 var(--control-height);
    padding: 0;
    gap: 0;
}
.btn-split > .btn-split-main .btn-split-label,
.btn-split.is-main-open > .btn-split-side .btn-split-label,
.btn-split:has(> .btn-split-main:focus-visible) > .btn-split-side .btn-split-label {
    max-width: 0;
    opacity: 0;
    transition: max-width var(--split-t) var(--split-ease), opacity 0.12s ease;
}
/* OPEN main part. Touch gets it from .is-main-open only; hover is scoped to
   real hover devices so a tap's sticky :hover can't open it on its own. */
.btn-split.is-main-open > .btn-split-main,
.btn-split:has(> .btn-split-main:focus-visible) > .btn-split-main {
    flex: 1 1 0;
    padding: 0.3em 10px;
    gap: 6px;
}
.btn-split.is-main-open > .btn-split-main .btn-split-label,
.btn-split:has(> .btn-split-main:focus-visible) > .btn-split-main .btn-split-label {
    max-width: 14em;
    opacity: 1;
    transition: max-width var(--split-t) var(--split-ease), opacity 0.25s var(--split-ease) 0.12s;
}
@media (hover: hover) {
    .btn-split:has(> .btn-split-main:hover) > .btn-split-main { flex: 1 1 0; padding: 0.3em 10px; gap: 6px; }
    .btn-split:has(> .btn-split-main:hover) > .btn-split-main .btn-split-label {
        max-width: 14em;
        opacity: 1;
        transition: max-width var(--split-t) var(--split-ease), opacity 0.25s var(--split-ease) 0.12s;
    }
    .btn-split:has(> .btn-split-main:hover) > .btn-split-side { flex: 0 0 var(--control-height); padding: 0; gap: 0; }
    .btn-split:has(> .btn-split-main:hover) > .btn-split-side .btn-split-label {
        max-width: 0;
        opacity: 0;
        transition: max-width var(--split-t) var(--split-ease), opacity 0.12s ease;
    }
}
@media (prefers-reduced-motion: reduce) {
    .btn-split > *,
    .btn-split-label { transition: none !important; }
}

/* Square modifier — an icon-only button: the svg IS the content, so the label
   padding goes and the width matches the height tier. An equal-sided box that
   styles no type is an icon frame, not a label, so the px-derived
   --control-height is right here; the base min-height still lets it grow. */
.btn-square { width: var(--control-height); padding: 0; }

/* Icon Helper */
.btn svg,
[class*="btn-"] svg {
    margin-right: 0;
    /* Allow flex gap to handle spacing usually, but user asked for specificity */
}

/* If the user wants specific icon classes like .icon-added */
.icon-added svg {
    /* Assuming flex gap handles it, but if manual spacing is needed: */
    /* margin-right: 5px; */
}

/* ── Badge / pill (universal) ─────────────────────────────────────────
   ONE base + color modifiers, exactly like the button system. Use these
   everywhere a small status/category pill is needed. The color reflects
   meaning, never the place of use — pick the variant via the badgeColor()
   helper (admin) or a direct color class. Never create function-named
   pill classes (.status-active, .source-feed, …) again. */
.badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    margin: 2px 0;
    padding: 2px 8px;
    border-radius: var(--radius-lg);
    font-size: var(--fs-xs);
    font-weight: 600;
    white-space: nowrap;
}
.badge-green  { background: var(--pill-green-bg);  color: var(--pill-green-text); }
.badge-yellow { background: var(--pill-yellow-bg); color: var(--pill-yellow-text); }
.badge-grey   { background: var(--pill-grey-bg);   color: var(--pill-grey-text); }
.badge-red    { background: var(--pill-red-bg);    color: var(--pill-red-text); }
.badge-blue   { background: var(--pill-blue-bg);   color: var(--pill-blue-text); }
.badge-purple { background: var(--pill-purple-bg); color: var(--pill-purple-text); }
.badge-pink   { background: var(--pill-pink-bg);   color: var(--pill-pink-text); }
/* Filled blue — the .btn-blue (Buy now) fill, for a badge that must read on a
   pale-blue surface where .badge-blue dissolves (Compare prices "current"). */
.badge-blue-solid { background: var(--blue-highlight); color: #fff; }
.badge-faded  { opacity: 0.6; }
/* Filter-trigger count badge (SortFilterBar's .btn-neutral) — outlined with the
   same 1px stroke as chips (matches the .chip.active pale-pink + pink look). */
.btn-neutral .badge,
.notif-count { border: 1px solid var(--pink-highlight); }   /* + header counters (core.css) */
.badge-sm     { font-size: var(--fs-xs); padding: 1px 6px; margin: 0; }  /* dense contexts: no vertical margin */
.badge.clickable { cursor: pointer; }


.badge.clickable:hover,
.badge.clickable:active { filter: brightness(0.92); }
/* Wrapping row of badges (user role lists, …). */
.badge-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
/* Vertical stack of badges, one per line (admin shops' Platform column, …). */
.badge-stack { display: flex; flex-direction: column; align-items: center; gap: 4px; }

/* ── Chip (universal interactive) ─────────────────────────────────────
   The interactive counterpart of .badge: white + border = "an item you
   can act on"; tinted + borderless (.badge) = a read-only fact. Chips
   whose WHOLE BODY acts (toggles, triggers) are <button class="chip"> —
   the element itself carries the cursor + hover affordance. Chips that
   only host action icons (<chip> component: favicon/label + ✕/pencil
   buttons) are <span>s and stay calm; their inner buttons signal the
   action. Action icons inherit the chip's text color and are AppIcons,
   never inline glyphs. */
.chip {
    display: inline-flex; align-items: center; gap: 5px;
    /* A floor, not a fixture — 24px is the shape at normal text size, and the em
       padding is what grows when the label is scaled on its own (same rule as
       .btn). A pinned 24px spilled the label out the bottom. */
    min-height: 24px; padding: 0.1em 10px;
    border: 1px solid #e0e0e0; border-radius: var(--radius-lg);
    background: var(--surface); color: #555;
    font-family: inherit; font-size: var(--fs-base); font-weight: 500;
    /* Flex centring owns the vertical rhythm; line-height:normal lets the font's
       symmetric leading sit the label dead-centre (a flat 1 made the text-strut
       tighter than the glyph and pushed it off). The favicon / ✕ are
       flex-centred independently. */
    line-height: normal;
    /* normal, not nowrap: a chip is often a bare <button class="chip">Label</button>,
       and text-overflow does nothing on a flex container (the label becomes an
       anonymous item), so nowrap here bought a hard clip, not an ellipsis —
       measured, 276px of label inside a 122px chip. Wrapping grows the chip
       instead. .chip-label declares its own nowrap, where the ellipsis applies. */
    white-space: normal; transition: all 0.15s;
    /* A <button> comes with text-align:center from the UA sheet. A one-line chip
       hides it (the box hugs its label), but the moment a label wraps — which the
       rule above deliberately allows — the lines centre against the left-aligned
       chips beside them. Measured in the category filter: "Свободно време и
       безопасност" widened to the panel and centred both rows. */
    text-align: left;
    max-width: 100%;   /* never overflow the row — long labels truncate via .chip-label */
    overflow: hidden;
    /* max-width:100% is not enough on its own: as a FLEX item a chip refuses to
       go below its min-content, so one long label set a whole header row's
       min-content and pushed the page past the viewport (measured 556px at 390px
       on /saved). min-width:0 lets it shrink; .chip-label supplies the ellipsis.
       Still needed with wrapping — min-content is the longest single word, which
       a Bulgarian list name clears easily in a narrow column. */
    min-width: 0;
}
/* Truncatable label inside a chip (e.g. a long list-name filter chip):
   ellipsis instead of pushing the chip past the viewport. The × / favicon,
   being separate flex items, stay put. The nowrap is declared HERE rather than
   inherited from .chip — it is what makes the ellipsis apply, and it belongs on
   the one element where truncating is the intended behaviour. */
.chip-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button.chip { cursor: pointer; }
.chip.active { background: var(--pink-highlight-pale); border-color: var(--pink-highlight); color: var(--pink-highlight); font-weight: 600; }
/* Momentary press — fires only while the finger/mouse is down and releases on
   lift, so it never latches on touch the way :hover does. */
button.chip.active:active {
    /* lift off the pale tint to plain white, foreground stays the active pink */
    background: var(--surface);
    border-color: var(--pink-highlight);
    color: var(--pink-highlight);
}
/* Hover feedback only where a real pointer exists. On touch, :hover sticks
   after a tap and leaves the chip looking permanently pressed. */
@media (hover: hover) {
    button.chip:hover { border-color: var(--pink-highlight); color: var(--pink-highlight); }
    button.chip.active:hover {
        background: var(--surface);
        border-color: var(--pink-highlight);
        color: var(--pink-highlight);
    }
}
.chip:disabled { opacity: 0.55; cursor: not-allowed; }
/* Larger variant — touch-friendly pickers (track-price list chips). */
.chip-lg { min-height: 32px; padding: 0.15em 12px; border-radius: 16px; font-size: var(--fs-base); gap: 6px; }
/* Accent modifier — tints only the trailing icon brand-pink; the label stays
   neutral so the icon reads as the action cue (the large "N lists"
   Manage chip on /saved). Works via currentColor: app-icon strokes with it. */
.chip-accent svg { color: var(--pink-highlight); }
.chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
/* ── Recent-search chip row (<recent-searches>) ──────────────────────────
   Shared by the /search hero and the homepage hero. Lived in pages/search.css
   until the homepage adopted the row; only per-page SPACING stays in the page
   sheets. */
.search-hero-recents {
    flex-wrap: nowrap;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    width: max-content;
    max-width: min(640px, 100%);
    cursor: grab;
}
.search-hero-recents::-webkit-scrollbar { display: none; }
.search-hero-recents.is-dragging { cursor: grabbing; }
/* While dragging, kill selection so the pull doesn't highlight the phrases. */
.search-hero-recents.is-dragging .chip { user-select: none; }

/* The universal .chip wraps its label (`white-space: normal`, deliberate — see
   components.css) and is shrinkable. Both are right for a wrapping .chip-row and
   wrong inside a scroll track: together they squash ten chips into the container
   instead of overflowing it (measured — the row stayed exactly 640px wide and
   every label wrapped internally). Overridden HERE only; the register is
   untouched. */
.search-hero-recents .chip { flex: 0 0 auto; white-space: nowrap; }

/* Scrollability hint — the scrollbar is hidden, so the fade is the only thing
   telling a mouse user there is more. Each edge fades only while that direction
   still has somewhere to go (classes come from syncRecentsFade). */
.search-hero-recents.fade-end {
    -webkit-mask-image: linear-gradient(to left, transparent, #000 28px);
            mask-image: linear-gradient(to left, transparent, #000 28px);
}
.search-hero-recents.fade-start {
    -webkit-mask-image: linear-gradient(to right, transparent, #000 28px);
            mask-image: linear-gradient(to right, transparent, #000 28px);
}
.search-hero-recents.fade-start.fade-end {
    -webkit-mask-image: linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent);
            mask-image: linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent);
}
.chip-favicon { width: 14px; height: 14px; object-fit: contain; border-radius: 2px; }
/* Avatar modifier — a person chip (followed stars) carries a round, slightly
   larger photo instead of the square favicon. Reduced left padding lets the
   avatar hug the edge, the shape that reads as a person chip. */
.chip-avatar { padding-left: 3px; }
.chip-avatar .chip-favicon { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
/* Real <button>s (keyboard focus/activation) — reset the button chrome.
   line-height:0 collapses the button's own text-strut so its box is exactly the
   icon; the svg goes display:block to drop the inline-baseline gap. Together they
   make the ✕ / pencil sit dead-centre on the chip's cross-axis. */
.chip-edit, .chip-remove { cursor: pointer; display: inline-flex; align-items: center; background: none; border: 0; padding: 0; color: inherit; line-height: 0; flex: 0 0 auto; }
.chip-edit svg, .chip-remove svg { display: block; }
.chip-edit { opacity: 0.6; }
.chip-edit:hover { opacity: 1; }
/* Count modifier — the muted "N" / "N / M" counter next to titles, tabs and
   section headers (was .admin-count / .mystuff-count). Use with the base:
   class="badge badge-count". Metadata, not a status: regular weight, a bit
   larger, fully rounded.

   The colours are .badge-grey's, not a hand-rolled pair: #999 on #f0f0f0 was
   2.50:1, a WCAG AA failure axe reports on /browse, and it was two hardcodes
   the palette already had a home for. --pill-grey-text on --pill-grey-bg is
   5.15:1. Being metadata is not an exemption — 1.4.3 applies to any text a
   sighted user is meant to read, and a count nobody can read is not "muted",
   it is missing. Muted now comes from weight and hue rather than from being
   faint. */
.badge-count {
    font-size: var(--fs-base);
    font-weight: 400;
    color: var(--pill-grey-text);
    background: var(--pill-grey-bg);
    border-radius: 20px;
    padding: 2px 10px;
}

/* ── Notice (universal alert/info box) ────────────────────────────────
   ONE base + color modifiers, like the badge system (was admin-only
   .feed-alert, generalized 2026-06-11). The color reflects meaning:
   negative = errors and actionable problems, positive = success,
   neutral = informational (paused, scheduled, FYI).
   Anatomy — a flex row of optional slots around the body:
     <div class="notice notice-negative">
       <app-icon name="alert-circle" size="16">      optional icon
       <div class="notice-body">
         <strong>Title</strong>                      optional; flows inline
         text / extra <div> lines                    with the text
       </div>
       <span class="text-muted text-nowrap">…</span> optional date, pushed right
       <button class="standard-close-btn">           optional dismiss
         <app-icon name="close" size="14">
     </div> */
.notice {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 4px 0;
    padding: 10px 12px;
    border: 1px solid;
    border-radius: var(--radius-control);
    font-size: var(--fs-base);
}
.notice-negative { background: var(--pill-red-bg);   color: var(--pill-red-text);   border-color: #f5c2c7; }
.notice-positive { background: var(--pill-green-bg); color: var(--pill-green-text); border-color: #badbcc; }
.notice-neutral  { background: var(--pill-blue-bg);  color: var(--pill-blue-text);  border-color: #b6d4fe; }
/* warning = an incomplete state the reader has to finish (an unverified email,
   a change waiting on a confirmation link) — louder than neutral, but nothing
   has gone wrong yet, so not negative. */
.notice-warning  { background: var(--pill-yellow-bg); color: var(--pill-yellow-text); border-color: #ffecb5; }
.notice > svg { flex-shrink: 0; }
.notice-body { flex: 1; min-width: 0; }
.notice-body > strong { margin-right: 6px; }  /* title runs inline, just a breath before the text */
.notice .standard-close-btn { color: inherit; opacity: 0.5; padding: 0; }
.notice .standard-close-btn:hover { color: inherit; opacity: 1; }

/* The address a verification link is going to (VerifyReminderContent), sat next
   to .notice because they share that modal and read as one column.

   Deliberately NOT a .notice: a notice reports what just happened and is read
   once, while this is the fact the reader has to check against their own inbox
   — most "I never got it" is a mistyped address, which cannot be spotted in
   body copy. So it is the largest type in any modal body we ship, and the pink
   rule ties it to the modal's own header icon rather than adding a colour.

   Padding is em and there is no height: the box is sized off its text, so an
   in-app WebView's textZoom grows the frame with the address instead of
   spilling it. overflow-wrap rather than nowrap for the same reason — a long
   address wraps, and there is nowhere for it to scroll. */
.verify-address {
    border-left: 3px solid var(--pink-highlight);
    background: var(--bg-light);
    border-radius: var(--radius-control);
    padding: 0.75em 0.9em;
    margin: 14px 0;
}
.verify-address-label {
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-light);
}
.verify-address-value {
    font-size: var(--fs-xl);
    font-weight: 600;
    color: var(--text-color);
    overflow-wrap: anywhere;
}
.verify-address-note { font-size: var(--fs-xs); color: var(--text-light); }

/* Header UI-language switcher (Phase B pilot) — current-language pill + dropdown.
   Hidden on mobile for now (the crowded mobile header gets it in the menu later). */
.header-lang-switch { position: relative; display: inline-flex; align-items: center; }
.header-lang-trigger { display: inline-flex; align-items: center; gap: 3px; }
.header-lang-backdrop { position: fixed; inset: 0; z-index: 99; }
.header-lang-menu {
    position: absolute;
    top: 100%;
    right: 0;
    margin-top: 6px;
    z-index: 100;
    min-width: 150px;
    padding: 4px;
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-md);
    display: flex;
    flex-direction: column;
    gap: 2px;
}
.header-lang-option {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 7px 10px;
    border-radius: var(--radius-control);
    font-size: var(--fs-base);
    color: var(--text-color);
    cursor: pointer;
    white-space: nowrap;
}
.header-lang-option:hover { background: var(--bg-light); }
.header-lang-option strong { min-width: 22px; font-size: var(--fs-xs); color: var(--text-light); }
.header-lang-option.active { color: var(--pink-highlight); font-weight: 600; }
.header-lang-option.active strong { color: var(--pink-highlight); }
@media (max-width: 700px) { .header-lang-switch { display: none; } }

/* Mobile menu: the language switcher sits on the right of the "My Profile" row
   (the header one is hidden < 700px). Reuses .header-lang-trigger / -menu /
   -option / -backdrop; only the container differs (visible, not display:none). */
.menu-profile-row { display: flex; align-items: center; padding-right: 20px; }
.menu-profile-row .menu-item { flex: 1; }
.menu-lang-switch { position: relative; flex-shrink: 0; }
/* Country + language pills side by side (i18n Phase 2; country renders only on
   multi-market installs). Adjacent-sibling rule so a lone switcher is unmoved. */
.menu-lang-switch + .menu-lang-switch { margin-left: 8px; }

/* Existing .action-btn styles from style.css */
.action-btn {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 5px;
    padding: 10px;
    border: none;
    border-radius: var(--radius-md);
    font-weight: 500;
    cursor: pointer;
    text-decoration: none;
    font-size: var(--fs-md);
}

.action-btn.blue {
    background-color: var(--btn-primary-bg);
    color: var(--btn-primary-text);
    transition: background-color 0.2s;
}

@media (hover: hover) {
    .action-btn.blue:hover {
        background-color: var(--btn-primary-hover);
    }
}

.action-btn.pink-filled {
    background: var(--btn-primary-bg);
    color: var(--btn-primary-text);
    padding: 8px 10px;
    border-radius: var(--radius-md);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-weight: 500;
    width: fit-content !important;
    cursor: pointer;
    border: none;
    transition: opacity 0.2s;
    white-space: nowrap;
    font-size: var(--fs-base);
    flex-shrink: 0;
}

@media (hover: hover) {
    .action-btn.pink-filled:hover {
        opacity: 0.9;
        color: var(--btn-primary-text);
    }
}

/* Actions Bar (Mobile Only) */
.actions-bar {
    display: flex;
    gap: 10px;
    margin-bottom: 15px;
}

@media (min-width: 851px) {
    .actions-bar {
        display: none !important;
    }
}

/* =========================================
   Product Cards
   ========================================= */
.product-card {
    display: flex;
    /* Hairline AND elevation, unlike .compact-card which carries the shadow
       alone. Deliberate divergence, not drift: a rail card is a small object
       floating in a scroll track, where an outline reads as weight; this one is
       a wide, content-dense row on a white page, where the border is what
       actually terminates it — the shadow alone left the long right edge
       dissolving into the background. The shadow supplies the lift, the border
       supplies the edge.
       `overflow: hidden` clips the card's own children, never its shadow, and
       the global `* { box-sizing: border-box }` means the 1px comes out of the
       inner box — the grid's column widths do not move either way. */
    /* 1px, not 0.5px: Chrome rounds a non-zero border-width UP to one CSS
       pixel, so 0.5px renders byte-for-byte identically to 1px — measured at
       device_scale_factor 1 AND 2, with getComputedStyle reporting the used
       value as "1px" both times. Writing 0.5 would only look deliberate in the
       source while changing nothing. If this edge needs to read lighter, the
       lever is the COLOUR, not the width. */
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    padding: 0;
    margin-bottom: 15px;
    background: var(--surface);
    position: relative;
    overflow: hidden;
    box-shadow: var(--shadow-sm);
}

/* ── Remove confirmation overlay ────────────────────────────────────── */
.product-card-remove-overlay {
    position: absolute;
    inset: 0;
    background: rgba(255, 255, 255, 0.96);
    border-radius: var(--radius-md);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 14px;
    padding: 24px 20px;
    z-index: 50; /* above discount-badge (10) AND admin-hint-btn (30) / admin-hint-popup (40) */
    animation: fadeIn 0.15s ease;
}

.product-card-remove-msg {
    font-size: var(--fs-md);
    font-weight: 600;
    color: var(--text-color);
    text-align: center;
    margin: 0;
    line-height: 1.4;
}

.product-card-remove-actions {
    display: flex;
    flex-direction: column;
    gap: 8px;
    width: 100%;
    max-width: 190px;
}

.product-card-remove-actions button {
    width: 100%;
}

/* ── Add-to-list success overlay (transient ~1.5s) ────────────── */
.product-card-success-overlay {
    position: absolute;
    inset: 0;
    /* Lighter sheet than the remove overlay's 0.96: this one CONFIRMS, it does
       not ask, so the product underneath should stay recognisable behind it.
       0.92 is the same wash .tp-confirm uses over the track-price modal frame —
       one value for "a confirmation is sitting on top of this", not two. */
    background: rgba(255, 255, 255, 0.92);
    border-radius: var(--radius-md);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 12px;
    padding: 20px;
    z-index: 50; /* above the corner badges, same layer as the remove overlay */
    color: var(--pink-highlight); /* the filled bookmark inherits this */
    text-align: center;
    animation: fadeIn 0.15s ease;
}
.product-card-success-msg {
    margin: 0;
    font-size: var(--fs-md);
    font-weight: 600;
    color: var(--text-color);
    line-height: 1.4;
}
/* Success-overlay CTA: ~60% width centered, not full-bleed like btn-block —
   matches the lighter footprint of the sibling remove-overlay buttons. */
.product-card-success-btn {
    width: 60%;
    min-width: 120px;
}

.product-left-col {
    padding: 10px;
    text-align: center;
    display: grid;
    grid-template-rows: 1fr auto;
    width: 50%;
    min-width: 0;
}

/* The full-width "Track price / Active tracking" button sits in the narrow 50%
   left column, where long localized labels (BG "Активно следене") have the least
   room. Wrapping and the min-height floor are the base button's behaviour now —
   this rule found that pattern first, for this one cell. What is still local:
   tighter padding and leading so a wrapped 2-line label stays compact, and
   min-width:0 so the flex item may shrink below its min-content width. */
.product-left-col .btn-block {
    padding: 4px 10px;
    line-height: 1.15;
    min-width: 0;
}

/* Wraps all left-column content above the button */
.product-left-content {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 0;
}
/* Mobile: top-align the image + its bookmark/heart overlay. .product-left-col is
   a `1fr auto` grid whose first row STRETCHES to match the right column, so
   justify-content:center floated the image to the middle of that row — a card
   whose right column wrapped to more lines pushed the image visibly down
   (measured on a star page: 83px from the card top at 360px vs 44px at 390px,
   same card, same image). A card should always start with its image. Desktop
   (≥851px) keeps the centred balance it was designed with. */
@media (max-width: 850px) {
    .product-left-content { justify-content: flex-start; }
}

.product-image-container {
    margin-bottom: 10px;
    position: relative;
    overflow: visible;
    width: 100%;
}

.product-img {
    width: 140px;
    /*height: 100px;*/
    aspect-ratio: 1 / 1;
    object-fit: contain;
    border-radius: var(--radius-sm);
    background-color: var(--surface);
    transition: transform 0.3s ease;
}

.product-image-container:hover .product-img {
    transform: scale(1.1);
}

/* Failed/missing product image — onProductImageError swaps src to the eshops
   logo so the slot keeps its square footprint instead of collapsing the card. */
.product-img.img-placeholder {
    opacity: 0.3;
    padding: 15% 10%;
}

.product-image-container:hover .img-placeholder {
    transform: none;
}

.discount-badge {
    position: absolute;
    top: 0;
    right: 0;
    background-color: #f8e1ea;
    color: var(--pink-highlight);
    font-size: var(--fs-base);
    font-weight: bold;
    display: flex;
    align-items: center;
    justify-content: center;
    /* em, not px: 3.088em x 13.6px (--fs-base) is the 42px this has always been,
       and the circle now tracks its own label. That is what survives text being
       scaled on its own, instead of letting the "-60%" out through the side. rem
       would not: such a scale raises this label's computed size, not the root's. */
    width: 3.088em;
    height: 3.088em;
    padding: 0;
    border-radius: 50%;
    z-index: 10;
    box-shadow: var(--shadow-sm);
}

/* ── Inactive product badge ─────────────────────────────────────────── */
.inactive-badge {
    position: absolute;
    bottom: 6px;
    left: 6px;
    background: var(--pill-grey-bg);
    color: var(--pill-grey-text);
    font-size: var(--fs-xs);
    font-weight: 600;
    padding: 2px 7px;
    border-radius: var(--radius-sm);
    z-index: 10;
    line-height: 1.3;
}

/* ── "New to the catalog" badge ─────────────────────────────────────
   Bottom-RIGHT because it is the last free corner of the image: top-left is
   the bookmark, top-right the discount circle, bottom-left the Inactive pill
   and the admin CDN cue. Colour and shape come from the universal `.badge
   .badge-blue` (see the badge system above) — this rule only places it, the
   same split the star card's .celeb-card-new uses. */
.product-card-new {
    position: absolute;
    bottom: 6px;
    right: 6px;
    z-index: 10;
    /* Decorative: the image behind it links to the shop, and a dead patch in
       the corner is a worse trade than a label you cannot click. */
    pointer-events: none;
    margin: 0;
    /* The stored strings are lowercase in all four languages ("ново", "new",
       "nou"), so `capitalize` is what raises the first letter. Single words,
       so it cannot mis-title a phrase. Mirrors .celeb-card-new. */
    text-transform: capitalize;
    letter-spacing: 0.04em;
}

/* ── Inactive product card (My Stuff / Lists only) ───────────── */
.product-card.product-inactive {
    filter: grayscale(40%) opacity(0.6);
}

@media (hover: hover) {
    .product-card.product-inactive:hover {
        filter: grayscale(20%) opacity(0.8);
    }
}

.product-card.product-inactive .product-shop-link,
.product-card.product-inactive .product-title {
    color: var(--text-light);
}

.product-card.product-inactive .product-price {
    color: var(--text-light);
}

/* Inactive products stay dimmed, but "Buy now" must still open the product
   page (the listing may be back / the shop page still works). Keep the dim,
   drop the click-block. */
.product-card.product-inactive .product-buy-btn {
    opacity: 0.5;
}

.icon-favorite {
    position: absolute;
    top: 0;
    left: 0;
    cursor: pointer;
    transition: all 0.2s ease;
    z-index: 10;
    fill: var(--pink-highlight) !important;
}

/* These carry role="button" tabindex="0", so tapping one FOCUSES it — and on
   touch there is no blur afterwards, so the browser's own `outline: auto
   <n>px -webkit-focus-ring-color` sticks around the glyph until the user taps
   elsewhere. Measured on a tap: :focus true, :focus-visible false. It shows up
   worst on the /stars follow heart, whose handler toggles in place instead of
   opening a modal that would pull focus away.
   Kill the pointer ring only — the `:not(:focus-visible)` form leaves the
   keyboard ring (further down, .icon-favorite:focus-visible) fully intact, and
   is order-independent unlike a bare `:focus { outline: none }`. */
.icon-favorite:focus:not(:focus-visible) {
    outline: none;
}

/* Outline-only variant — overrides the filled pink above */
.icon-favorite-outline {
    fill: none !important;
}

/* Full card: optically flush the bookmark with the "Track price" button below.
   The two boxes already share a left edge (both start at the .product-left-col
   padding), but the glyph does not: the bookmark path spans x=5..19 in a
   `0 0 24 24` viewBox with a 2px stroke, so its ink starts 4/24 of the box in
   and the icon read as indented. Pull the box left by exactly that dead strip
   — scaled off the 28px render size, so this stays correct if the size prop in
   ProductCard.js moves again. Scoped to .product-card: the compact cards use a
   20px bookmark and the hot card pins it top-RIGHT, neither of which wants
   this nudge. */
.product-card .product-image-container > .icon-favorite {
    left: calc(28px * -4 / 24);
}

.product-brand {
    font-size: var(--fs-xs);
    font-weight: bold;
    /* was a raw #999 = 2.85:1, an AA fail on the brand line of every card
       (bold, but --fs-xs is far below the 18.66px large-text exemption). */
    color: var(--text-light);
    text-transform: uppercase;
    margin-bottom: 8px;
    line-height: 1.2;
}
/* The brand as a filter link (js/lib/brandLink.js — ProductCard, GroupCard,
   CarouselCard, HotProductCard). Same look as the plain line at rest;
   inline-block so only the name is the target and a centred card keeps it
   centred; pink + underline on hover like every other text link. */
.product-brand-link {
    display: inline-block;
    max-width: 100%;
    overflow-wrap: anywhere;
    text-decoration: none;
    text-underline-offset: 2px;
}
.product-brand-link:link,
.product-brand-link:visited { color: var(--text-light); }
@media (hover: hover) {
    .product-brand-link:hover { color: var(--pink-highlight); text-decoration: underline; }
}
.product-brand-link:focus-visible { color: var(--pink-highlight); }

/* Size / colour line on ProductCard + GroupCard offers — variant disambiguation.
   Segments come from js/lib/variantLabel.js; the size is a .badge (read-only
   fact), so the row is a flex line that keeps the pill and text centred. */
.product-variant-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    font-size: var(--fs-xs);
    color: var(--text-light);
    margin-bottom: 8px;
    line-height: 1.2;
}
.product-variant-sep { margin: 0 4px; opacity: 0.6; }
/* The pill follows the row's size (compact tiles shrink it to --fs-3xs). */
.product-variant-meta .badge { font-size: inherit; }
.compact-card .product-variant-meta { font-size: var(--fs-3xs); margin-bottom: 4px; }
/* "Seen by N people this month" — the same muted row, with the eye icon. */
.product-seen-meta { gap: 4px; }

/* Current price and struck old price share ONE row, "2.70 € / 3.21 €".
   Affordable now that the BG euro-transition dual display is switched off
   (js/lib/money.js dualPrice) — each priceLine() is a single short string,
   so the pair fits where two stacked lines used to be needed. If the leva
   half ever comes back, this goes back to `flex-direction: column`.
   Every card that renders .product-price-block gets the row: ProductCard,
   CarouselCard, GroupCard's offer tiles, and HotProductCard — which used to
   carry its own .price-row > .price copy of this and no longer does. */
.product-price-block {
    margin-bottom: 10px;
    /* Reserve enough height that a card without an old price still lines up
       with one that has it. Kept even though both now sit on a single line:
       it is what keeps the Buy/Track buttons level across a grid row. */
    min-height: 48px;
    display: flex;
    flex-direction: row;
    /* align-items:center, not baseline — the min-height above is a reserve,
       and a baseline group would sit flush at its top instead of centred in
       it. The two font sizes are close enough that centred reads as level. */
    align-items: center;
    /* The full card's left column and the compact tiles are both
       text-align:center, so the pair centres like every other line. */
    justify-content: center;
    gap: 6px;
    /* Long price + long old price in a narrow tile: wrap rather than overflow
       the card. The separator rides on the price, not the old price, so a
       wrap never leaves a dangling slash at the end of the first line. */
    flex-wrap: wrap;
}

.product-price {
    /* em, so the price keeps tracking whatever card it sits in — the card sets
       the context, this just says "slightly smaller than it". max() is the
       floor guard: 0.96em would resolve into single digits in a compact card,
       small enough that a browser minimum-font-size setting could clamp it back
       up and break the em relationship this rule exists to keep.
       --fs-3xs, not --fs-xs, so the em still wins in a normally-sized card and
       the floor only takes over in a genuinely compact one. */
    font-size: max(0.96em, var(--fs-3xs));
    font-weight: bold;
    color: var(--text-color);
}

.product-price.discounted {
    color: var(--pink-highlight);
}

.product-old-price {
    text-decoration: line-through;
    font-size: var(--fs-sm);
    color: var(--text-light);
}

/* The " / " delimiter is presentation, so it lives here rather than in the
   template or an i18n string. Hung off .product-price.discounted — all three
   components set that class on exactly the condition that renders
   .product-old-price (original_price > price), so the two can never disagree.
   It must NOT hang off .product-old-price::before: line-through propagates
   into a pseudo-element of the struck box and cannot be turned off there,
   which would strike the slash too. */
.product-price.discounted::after {
    content: "/";
    margin-left: 6px;
    color: var(--text-light);
    font-weight: normal;
}

/* ── Price freshness indicator ─────────────────────────────────────────── */

.price-freshness {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* em, so the disc tracks its own glyph: a pinned 12px pair clipped the "!"
       the moment anything lifted the label above that. */
    width: 1.25em;
    height: 1.25em;
    border-radius: 50%;
    font-size: var(--fs-3xs);
    font-weight: 700;
    font-style: normal;
    cursor: help;
    vertical-align: middle;
    margin-right: 4px;
    position: relative;
}

.freshness-fresh { background: var(--pill-green-bg); color: var(--pill-green-text); }
.freshness-aging { background: var(--pill-yellow-bg); color: var(--pill-yellow-text); }
.freshness-stale { background: var(--pill-grey-bg); color: var(--pill-grey-text); }

/* Shared tooltips — slate --text-light bubble (white text 6.1:1, AA), appended
   to body, escape overflow:hidden. Placed by placeFloatingTip() in api.js,
   which sets left/top as the real box corner (no transform) and data-side /
   --tip-arrow-x for the arrow.
   #freshness-tip = card price marker + availability pill;
   #hint-tip      = every inline .tp-hint (site and admin). */
.freshness-tooltip,
.hint-tip {
    position: fixed;
    left: 0;
    top: 0;
    background: var(--text-light);
    color: #fff;
    padding: 4px 10px;
    border-radius: var(--radius-sm);
    font-size: var(--fs-sm);
    /* Bounded, and allowed to wrap: the JS clamp can only hold a box inside the
       viewport if the box fits in the first place. With nowrap a long label on a
       narrow phone was wider than the screen whatever left/top it was given.
       Short labels are well under 260px, so they still render on one line. */
    max-width: min(260px, calc(100vw - 16px));
    white-space: normal;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.15s;
    z-index: var(--z-tooltip);
}

.freshness-tooltip.visible,
.hint-tip.visible { opacity: 1; }

/* Inline hints carry sentences rather than a three-word label: the roomier
   box and the hint type step the in-place bubbles always had. width is
   max-content so a short hint stays compact; anywhere-wrapping so a regex or
   URL in an admin hint cannot push the box past max-width. */
.hint-tip {
    width: max-content;
    padding: 8px 10px;
    border-radius: var(--radius-md);
    font-size: var(--fs-xs);
    font-weight: 400;
    line-height: 1.45;
    text-align: left;
    overflow-wrap: anywhere;
}
.hint-tip--wide { max-width: min(480px, calc(100vw - 16px)); }
/* Pinned by a tap/click: a hint that holds a link must let it be followed. */
.hint-tip.is-pinned { pointer-events: auto; }
.hint-tip a { color: inherit; text-decoration: underline; }
/* An action inside a hint (admin product status -> "View product"): its own
   line under the explanation and one step down the ladder, so it reads as the
   tip's footnote rather than part of the sentence. Followable because
   .hint-tip.is-pinned above restores pointer-events once a click pins it. */
.hint-tip .hint-tip-link { display: block; width: max-content; margin-top: 6px; font-size: var(--fs-2xs); }

/* v-results-fade (js/lib/resultsFade.js): a result grid that changed says so.
   While a refetch is in flight the old cards are hidden (space kept), and a
   spinner fades in only past 400 ms; the new results fade in with a
   6px rise over 400 ms (owner 09-25: 220 ms felt abrupt). The whole grid moves as one — no per-card stagger. */
/* The old CARDS are hidden at once (owner 09-25: no fade-out, it read as a
   jump) but keep their space, so nothing below moves; the grid itself stays
   visible because the spinner is its ::before. */
.results-fade { position: relative; }
.results-fade.is-refreshing { pointer-events: none; }
.results-fade.is-refreshing > * { visibility: hidden; }
.results-fade.is-refreshing::before {
    content: '';
    position: absolute;
    top: 48px;
    left: 50%;
    z-index: 5;
    width: 32px;
    height: 32px;
    margin-left: -16px;
    /* .loading-spinner's look, a size down, on a white disc (the ring is
       drawn over cards) with the soft dropdown shadow. */
    border: 3px solid var(--border-color);
    background: var(--surface);
    box-shadow: 0 0 0 6px var(--surface), 0 6px 20px rgba(0, 0, 0, 0.14);
    border-top-color: var(--pink-highlight);
    border-radius: 50%;
    opacity: 0;
    animation: results-fade-spinner-in 0.2s ease 0.4s forwards, spin 0.8s linear infinite;
}
.results-fade.is-entering { animation: results-fade-in 0.4s cubic-bezier(0.25, 0.1, 0.25, 1); }
@keyframes results-fade-in {
    from { opacity: var(--results-fade-from, 0); transform: translateY(6px); }
    to   { opacity: 1; transform: none; }
}
@keyframes results-fade-spinner-in { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
    .results-fade.is-entering { animation-name: results-fade-in-flat; }
    @keyframes results-fade-in-flat { from { opacity: var(--results-fade-from, 0); } to { opacity: 1; } }
    /* The spinner keeps spinning — progress, not decoration. */
}

/* <view-mode-toggle> (ViewModeToggle.js): the grouped / list pills plus a
   "Group mode" / "List mode" bubble that a click flashes ABOVE them for 1.5 s.
   The bubble borrows the shared tooltip look (slate --text-light, white text,
   arrow) but lives in flow under a position:relative wrapper — it belongs to
   this control, not to a hover target. Enters with a soft fade + 4px rise and
   fades out slower than it came, so it drifts away rather than blinks. */
.view-mode-toggle { position: relative; display: inline-flex; }
/* A zero-size anchor at the pills' top centre. The bubble hangs off it with
   the standalone `translate` property, so the enter/leave animations (which
   use `transform`) never touch the centring — a leaving bubble used to lose
   its width and jump sideways as it faded. Both an entering and a leaving
   bubble are absolute, so a re-click cross-fades them in place. */
.view-mode-flash-region {
    position: absolute;
    left: 50%;
    bottom: calc(100% + 10px);
    width: 0;
    height: 0;
    pointer-events: none;
    z-index: var(--z-tooltip);
}
.view-mode-flash {
    position: absolute;
    left: 0;
    bottom: 0;
    translate: -50% 0;
    display: block;
    width: max-content;
    padding: 6px 12px;
    border-radius: var(--radius-md);
    background: var(--text-light);
    color: #fff;
    font-size: var(--fs-sm);
    font-weight: 500;
    white-space: nowrap;
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
}
.view-mode-flash::after {
    content: '';
    position: absolute;
    top: 100%;
    left: 50%;
    margin-left: -6px;
    border: 6px solid transparent;
    border-top-color: var(--text-light);
}
.view-mode-flash-enter-active { transition: opacity 0.25s ease-out, transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); }
.view-mode-flash-leave-active { transition: opacity 0.45s ease-in, transform 0.45s ease-in; }
.view-mode-flash-enter-from { opacity: 0; transform: translateY(4px) scale(0.96); }
.view-mode-flash-leave-to { opacity: 0; transform: translateY(-2px); }
@media (prefers-reduced-motion: reduce) {
    .view-mode-flash-enter-active,
    .view-mode-flash-leave-active { transition: opacity 0.15s linear; }
    .view-mode-flash-enter-from,
    .view-mode-flash-leave-to { transform: none; }
}

/* The arrow points at the trigger's centre, wherever the box had to slide. */
.freshness-tooltip::after,
.hint-tip::after {
    content: '';
    position: absolute;
    left: var(--tip-arrow-x, 50%);
    margin-left: -6px;
    border: 6px solid transparent;
}
.freshness-tooltip[data-side="above"]::after,
.hint-tip[data-side="above"]::after { top: 100%; border-top-color: var(--text-light); }
.freshness-tooltip[data-side="below"]::after,
.hint-tip[data-side="below"]::after { bottom: 100%; border-bottom-color: var(--text-light); }

/* Colour key under the price marker's tooltip. Rows are .legend-item and each
   swatch is a real .price-freshness disc, so the key cannot drift from what the
   card draws. */
.freshness-legend {
    display: grid;
    gap: 3px;
    margin-top: 6px;
    padding-top: 6px;
    border-top: 1px solid var(--border-color);
    font-size: var(--fs-xs);
}
.freshness-legend .price-freshness { margin-right: 0; }


/* Availability pill on ProductCard — color reflects the canonical state
   (in_stock / out_of_stock / preorder). The freshness-{fresh,aging,stale}
   modifier fades the pill with age so a stale signal reads as less
   authoritative without changing the underlying color semantics. */
.product-availability {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 2px 8px;
    border-radius: var(--radius-lg);
    font-size: var(--fs-xs);
    font-weight: 600;
    cursor: help;
    line-height: 1.4;
}
.product-availability-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: currentColor;
    flex-shrink: 0;
}
.product-availability-in_stock     { background: var(--pill-green-bg);  color: var(--pill-green-text); }
.product-availability-out_of_stock { background: var(--pill-red-bg);    color: var(--pill-red-text); }
.product-availability-preorder     { background: var(--pill-yellow-bg); color: var(--pill-yellow-text); }
.product-availability.freshness-aging { opacity: 0.85; }
.product-availability.freshness-stale { opacity: 0.55; }

/* The price and its struck original as ONE box, so the price-alert outline can
   enclose both. Inherits the row behaviour the block used to apply directly to
   them — same gap, same wrap, so an unmarked card renders exactly as before. */
.product-price-pair {
    display: inline-flex;
    align-items: center;
    flex-wrap: wrap;
    justify-content: center;
    gap: 6px;
}

/* Price-alert hit — the viewer's own threshold is met RIGHT NOW.
   OUTLINED in the brand pink, never filled and never green: the filled GREEN
   pill directly above is availability, and a second green chip under it would
   read as the same system talking about stock. Pink is already this card's
   "your alert" colour — the Track bell fills pink while tracking.
   Every pixel comes out of .product-price-block's 48px reserve (padding, not
   margin), so a marked card's Buy/Track buttons stay level with its
   neighbours' in the same grid row. */
.product-card .product-price-block.is-alert-hit {
    padding-top: 6px;   /* clears the availability pill above */
    row-gap: 2px;       /* the note reads as the outline's caption */
}
.product-card .product-price-block.is-alert-hit .product-price-pair {
    border: 1px solid var(--pink-highlight);
    border-radius: var(--radius-lg);
    padding: 0.1em 0.5em;
}
.product-card .price-alert-note {
    flex-basis: 100%;          /* its own line inside the wrapping price row */
    font-size: var(--fs-2xs);
    font-weight: 600;
    color: var(--text-color);
}
/* The Track button on a card whose alert has ARRIVED: hold the pale variant's
   own hover tint permanently. #f8e1ea is not a new colour — it is exactly what
   .btn-primary-pale:hover paints (see the Buttons block at the top of this
   file), so the state reads as "this one is already lit" rather than as a new
   button variant. Hover still applies and lands on the same value, so pointing
   at it does not undo the marking. */
.product-card .btn-primary-pale.is-alert-hit {
    background-color: #f8e1ea;
    border-color: #f8e1ea;
}


/* Product Action Buttons */

.product-actions-container {
    width: 100%;
    margin-top: auto;
}


.product-right-col {
    padding: 10px;
    display: grid;
    grid-template-rows: 1fr auto;
    position: relative;
    width: 50%;
    min-width: 0;
}

.product-right-col::before {
    content: "";
    position: absolute;
    left: 0;
    top: 15px;
    bottom: 15px;
    border-left: 1px dashed #ddd;
}

.product-shop-link {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: var(--fs-xs);
    font-weight: 500;
    color: var(--text-light);
    text-decoration: none;
    margin-bottom: 6px;               /* even air around the domain line (matches .product-list) */
}

.product-shop-link:hover { color: var(--pink-highlight); }

.product-title {
    font-size: var(--fs-lg);
    font-weight: 500;
    margin-bottom: 6px;
    line-height: 1.3;
    color: var(--text-color);
    /* Clamp: some shop titles run to 250 chars, and in the card's narrow right
       column that rendered as 13 lines eating 60% of the card (measured at
       360px). MORE lines on mobile than desktop is deliberate, not a typo — the
       column is half as wide, so the same line count shows roughly half the
       text. The full string stays reachable via the title attribute. */
    display: -webkit-box;
    -webkit-box-orient: vertical;
    overflow: hidden;
    -webkit-line-clamp: 5;
    line-clamp: 5;
}
@media (min-width: 851px) {
    .product-title { -webkit-line-clamp: 4; line-clamp: 4; }
}

/* Title is an outgoing shop link (same target as Buy now); inherit the
   heading's look, reveal it as a link only on hover. */
.product-title a { color: inherit; text-decoration: none; }
.product-title a:hover { color: var(--pink-highlight); }

/* List eyebrow — "which list owns this product" line above the
   title on /mystuff (all / shop-filtered views only). Plain highlighted text,
   NOT a badge: ownership metadata truncates gracefully as text, while pills
   are reserved for status/attributes. Name ellipsises on one line; "+N" marks
   membership in more lists (full list in the title tooltip). */
.product-list {
    display: flex;
    align-items: baseline;
    gap: 4px;
    min-width: 0;
    margin-bottom: 6px;               /* even air around the domain line (matches .product-shop-link) */
    font-size: var(--fs-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--pink-highlight);
    cursor: pointer;
}
.product-list-name {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.product-list-more { flex: 0 0 auto; color: var(--text-light); }
/* In the GroupCard header (flex row of title + badge) the eyebrow takes its
   own full-width first row. */
.group-list { flex: 1 1 100%; margin-bottom: 0; }

.separator-line {
    border-top: 1px dashed var(--border-color);
    margin-bottom: 10px;
    width: 100%;
}

/* Vertical variant — same dashed rule turned on its side, for separating
   groups inside a flex row (product-media circles vs the star stack). */
.separator-line.vertical {
    border-top: none;
    border-left: 1px dashed var(--border-color);
    width: 0;
    margin: 0 4px;
    align-self: stretch;
}

.actions-label {
    display: none;                    /* hidden for testing (2026-07-15) — remove to restore */
    margin-bottom: 8px;
    font-size: var(--fs-md);
    color: var(--text-color);
    font-weight: 500;
}

.shop-icon {
    width: 16px;
    height: 16px;
    object-fit: contain;
    flex-shrink: 0;
}

@media (max-width: 600px) {
    .product-title {
        font-size: var(--fs-lg);
    }
}

.product-actions {
    list-style: none;
    padding: 0;
    margin-bottom: auto;
}

.product-actions li {
    margin-bottom: 8px;
}
/* The row itself is the <button>, so the row's layout lives here rather than
   on the <li>. width/padding/border/background/font/text-align are the UA
   button defaults being undone — without them the row would shrink-wrap,
   centre its label and draw a chrome border. */
.product-actions li > button {
    display: flex;
    align-items: center;
    gap: 5px;
    width: 100%;
    padding: 0;
    border: none;
    background: none;
    text-align: left;
    font-family: inherit;
    font-weight: inherit;
    font-size: var(--fs-base);
    color: var(--text-color);
    cursor: pointer;
}
/* Leading action icon only — the trend icon inside .price-trend keeps its
   own pill styling (see .price-trend-* below). Still a direct-child selector,
   so the nested .price-trend > svg is excluded exactly as before. */
.product-actions li > button > svg { color: var(--pink-highlight); }
/* Hover affordance — same as .product-title a:hover. Pointer devices only
   (on touch, :hover latches after a tap). */
@media (hover: hover) {
    .product-actions li > button:hover { color: var(--pink-highlight); }
}

.product-actions li.text-light > button {
    color: var(--text-light);
}

/* Mobile tier: the action rows drop from --fs-base to the domain line's
   --fs-xs, so the right column's three text sizes read as one scale
   (.product-title --fs-lg > actions > .product-shop-link) instead of the
   actions sitting a step above the shop link in a half-card-wide column.
   Keyed to the existing 1200/1201 boundary the card's own mobile rules use
   (see .admin-hint-popup below) — no new breakpoint. Size only: the rows
   have no fixed box, and the leading icons keep their own px sizing. */
@media (max-width: 1200px) {
    .product-actions li > button {
        font-size: var(--fs-xs);
    }
}

/* Price-trend arrow on the "Price history" action: current price vs the
   90-day average (server-side threshold PRICE_TREND_THRESHOLD, see
   config/app.php price_trend_threshold). White arrow in a colored circle:
   green = trending down, pink = trending up, link-blue = flat. */
.price-trend {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    color: #fff;
    flex-shrink: 0;
}
/* -dark, like .btn-green: the glyph inside is #fff, and the pink/blue siblings
   already clear 3:1 — the base green was the only one that didn't. */
.price-trend-down { background: var(--green-highlight-dark); }
.price-trend-up   { background: var(--pink-highlight); }
.price-trend-flat { background: var(--blue-highlight); }

/* ── Product-media circles (ProductCard, between the actions and Buy now) ──
   One circle per content type attached to the product (video / article) plus
   a stack of star avatars for the stars whose public lists hold it.
   Each opens the media modal on its own tab. The row is absent entirely when
   the product has nothing attached — which is the common case, so it must add
   no height of its own. */
.pm-circles {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    /* Symmetric: the 10px above comes from this margin-top (the dashed
       .separator-line's own margin-bottom sits above that), so a 4px bottom
       left the row visibly crowded against the card edge on mobile, where it
       is the LAST element and nothing else contributes slack. Desktop is
       unaffected — the right column already has spare height there, so the
       extra 6px is absorbed rather than shown. */
    margin: 10px 0;
}

/* Rail cards (CarouselCard's star avatars): the compact tile has no dashed
   separator above the row and no card edge right below it, so the 10px that
   ProductCard needs reads as a gap here. */
.compact-card .pm-circles { margin: 5px 0; }

.pm-circle {
    position: relative;
    width: 34px;
    height: 34px;
    flex-shrink: 0;
    border-radius: 50%;
    border: 1px solid var(--border-color);
    background: var(--surface);
    color: var(--pink-highlight);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    padding: 0;
    transition: border-color 0.2s ease, background-color 0.2s ease;
}

@media (hover: hover) {
    .pm-circle:hover { border-color: var(--pink-highlight); background: var(--pink-highlight-pale); }
}

/* Empty slot — shown ONLY to someone who can attach media (admin / the shop's
   manager). Greyed so it reads as "nothing here yet", and it opens the attach
   modal instead of the viewer modal. */
.pm-circle--empty {
    color: var(--text-light);
    border-style: dashed;
}

@media (hover: hover) {
    .pm-circle--empty:hover { color: var(--pink-highlight); border-style: solid; }
}

/* Count rides the circle's top-right corner like a notification badge —
   same pill as the thumb-strip count (.thumb-strip-count): a
   .badge.badge-pink.badge-sm carries the look, this rule only places it.
   pointer-events: none so the badge never swallows a click meant for the
   circle button underneath it. */
.pm-circle-count {
    position: absolute;
    top: -4px;
    right: -4px;
    pointer-events: none;
}

/* Overlapping star avatars — one button, reads as a group. */
.pm-stack {
    display: inline-flex;
    align-items: center;
    border: none;
    background: none;
    padding: 0;
    cursor: pointer;
}

.pm-stack-avatar {
    /* 34px = the exact drawn size of .pm-circle (border-box), so the star
       stack sits on the same optical line as the video/article circles. */
    width: 34px;
    height: 34px;
    border-radius: 50%;
    object-fit: cover;
    border: 2px solid var(--surface);
    background: var(--surface);
    flex-shrink: 0;
}

/* Every avatar after the first tucks under the one before it. */
.pm-stack-avatar + .pm-stack-avatar { margin-left: -10px; }

/* 2.833em x 12px (--fs-xs) is the 34px that matches the .pm-stack-avatar images
   beside it; flex centring replaces the px line-height so an inflated "+3" grows
   the pill instead of escaping it. It then sits taller than the avatars, which is
   the right trade — they are images and have nothing to overflow. */
.pm-stack-more {
    margin-left: -10px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 2.833em;
    min-height: 2.833em;
    padding: 0 6px;
    border-radius: 17px;
    border: 2px solid var(--surface);
    background: var(--pill-grey-bg);
    color: var(--pill-grey-text);
    font-size: var(--fs-xs);
    font-weight: 700;
    line-height: normal;
}

@media (hover: hover) {
    .pm-stack:hover .pm-stack-avatar { border-color: var(--pink-highlight); }
}

/* ── Product-media modal (ProductMediaContent) ─────────────────────────────
   The Stars tab reuses .celeb-grid-container + <celebrity-card>, and the
   Articles tab reuses the modal product-row anatomy (.modal-product-preview
   and friends) — only the video tiles and the article excerpt need styles of
   their own. Video tiles are PORTRAIT (9:16): the sources are Shorts and
   TikToks, so a landscape thumb would letterbox on every card. */
/* One slide of the video carousel: portrait player/thumb, then title + source. */
.pm-video-slide {
    display: flex;
    flex-direction: column;
    gap: 6px;
    min-width: 0;
}

/* The built player. Same 9:16 box as the thumbnail it replaces, so activating a
   video doesn't reflow the carousel. Both providers' iframes fill it. */
.pm-video-frame {
    position: relative;
    width: 100%;
    aspect-ratio: 9 / 16;
    border-radius: var(--radius-md);
    overflow: hidden;
    background: #000;
}

.pm-video-frame iframe {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
    display: block;
}
/* A story's photo slide shares the frame with its video slides. */
.pm-video-frame img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

/* The thumbnail is a <button> in the carousel (tap = activate the player) —
   strip the button chrome so it looks identical to the plain tile. */
.pm-video-thumb-btn {
    border: none;
    padding: 0;
    cursor: pointer;
    font-family: inherit;
}

/* Embed refused by the video's owner — say so and offer the real link. */
.pm-video-failed {
    margin: 0;
    font-size: var(--fs-sm);
    color: var(--text-light);
    line-height: 1.35;
}
.pm-video-failed a { color: var(--pink-highlight); }

.pm-video-thumb {
    position: relative;
    display: block;
    width: 100%;
    aspect-ratio: 9 / 16;
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--pill-grey-bg);
}

/* CONTAIN, not cover: a landscape thumbnail (a normal YouTube video rather
   than a Short) must be shown whole inside the portrait tile, letterboxed on
   black, never cropped. */
.pm-video-thumb img {
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
    background: #000;
}

.pm-video-play {
    position: absolute;
    inset: 0;
    margin: auto;
    width: 44px;
    height: 44px;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.55);
    color: #fff;
    display: flex;
    align-items: center;
    justify-content: center;
}

.pm-video-duration {
    position: absolute;
    right: 6px;
    bottom: 6px;
    padding: 1px 6px;
    border-radius: 4px;
    background: rgba(0, 0, 0, 0.7);
    color: #fff;
    font-size: var(--fs-3xs);
    font-weight: 600;
}

.pm-video-title {
    font-size: var(--fs-base);
    font-weight: 600;
    color: var(--text-color);
    text-decoration: none;
    line-height: 1.25;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.pm-video-meta {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: var(--fs-sm);
    color: var(--text-light);
}

/* Modal subtitle carrying a glyph (e.g. the YouTube mark beside a video's
   channel in theatre mode). Only applied when an icon is actually passed, so
   every existing text-only subtitle renders exactly as before. */
.modal-header-text p.modal-subtitle-icon {
    display: inline-flex;
    align-items: center;
    gap: 5px;
}

@media (hover: hover) {
    .pm-video-slide:hover .pm-video-title { color: var(--pink-highlight); }
    .pm-video-thumb-btn:hover .pm-video-play { background: var(--pink-highlight); }
}

/* Splide arrows sit over the tall portrait slides; nudge them onto the media
   itself rather than the title text below it. */
.pm-video-splide .splide__arrow { top: 40%; }

/* ── Article cards (product-media modal, Articles tab) ────────────────────
   Same portrait slide rhythm as the video strip: image · title · a taste of the
   AI overview · source pinned to the bottom. The card carries no buttons — the
   image and the text open the overview, which holds "Visit article". */
.pm-article-slide {
    position: relative;   /* anchors the author edit pencil */
    display: flex;
    flex-direction: column;
    gap: 6px;
    min-width: 0;
    /* As tall as a video tile: --pm-slide-h is measured from the real video
       strip in equalisePanes(), so the two tabs read as one system whatever
       the slide width works out to. Falls back to content height. */
    min-height: var(--pm-slide-h, auto);
}

/* 3:2 — squarer than the 1.91:1 OpenGraph ratio publishers ship, so the tile
   carries more weight on the card, and the image COVERS it: an og:image is a
   crop-safe editorial picture (unlike a product shot or a video frame, which
   stay `contain`), and a filled box beats one with bars down the sides. */
.pm-article-image {
    display: block;
    width: 100%;
    /* Rendered as a <button> on the card (it opens the overview) and as a
       plain <span> in the modals — reset the button chrome either way. */
    border: 0;
    padding: 0;
    text-align: left;
    aspect-ratio: 3 / 2;
    border-radius: var(--radius-md);
    overflow: hidden;
    background: #000;
}

button.pm-article-image { cursor: pointer; }

.pm-article-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

/* Pinned to the card's foot (was under the image) so every slide ends on the
   same line, the way .pm-article-actions used to. */
.pm-article-source {
    display: flex;
    align-items: center;
    gap: 5px;
    margin: 0;
    padding-top: 8px;
    margin-top: auto;
    font-size: var(--fs-xs);
    color: var(--text-light);
}

.pm-article-title {
    /* Space on both sides of the headline — it sits between the image and the
       overview and was touching each of them. */
    margin: 4px 0 2px;
    font-size: var(--fs-md);
    font-weight: 600;
    color: var(--text-color);
    line-height: 1.3;
    cursor: pointer;
    display: -webkit-box;
    /* 3 lines now — the overview below it fills the rest of the card. */
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/* A taste of the AI overview, filling the space the two buttons used to take
   so an article card still stands as tall as a video tile. Clamped by LINES
   rather than characters: the box is what decides how much fits. */
.pm-article-summary {
    margin: 0;
    flex: 1 1 auto;
    font-size: var(--fs-sm);
    line-height: 1.45;
    color: var(--text-light);
    cursor: pointer;
    display: -webkit-box;
    -webkit-line-clamp: 8;
    -webkit-box-orient: vertical;
    overflow: hidden;
    /* The last ~50px dissolve into the card instead of ending on a hard cut,
       so the text reads as "there is more of this" — the mask fades to whatever
       is behind, so it needs no colour of its own. */
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 50px), transparent 100%);
    mask-image: linear-gradient(to bottom, #000 calc(100% - 50px), transparent 100%);
}

/* The sparkle marks the text as model-written; it sits on the first line. */
.pm-article-summary svg {
    color: var(--pink-highlight);
    vertical-align: -1px;
    margin-right: 4px;
}

.pm-article-splide .splide__arrow,
.pm-star-splide .splide__arrow { top: 35%; }

/* A star tile inside a 40% slide must fill it, exactly like the video and
   article tiles — .celeb-card is otherwise sized by its grid parent. */
.pm-star-splide .celeb-card { height: 100%; }

/* The summary modal shows an ARTICLE image, so it reuses .pm-article-image
   (3:2, cover) rather than the square .modal-product-img. In the
   preview row it needs a fixed width instead of the 100% it takes on a card. */
.modal-product-preview .pm-article-image {
    /* 20% up from 160px — the overview modal is where the picture is worth
       looking at, not just identifying the article. */
    width: 192px;
    flex-shrink: 0;
}

/* Stacked AI-summary modal. */
.pm-summary-text {
    margin: 0;
    /* One step up the scale — this is a paragraph to READ, not UI chrome. */
    font-size: var(--fs-lg);
    line-height: 1.55;
    color: var(--text-color);
    white-space: pre-line;
}

/* Editing the overview swaps the paragraph for a textarea of the same type
   size and rhythm, so the box doesn't jump when Edit is pressed. */
textarea.pm-summary-text {
    width: 100%;
    /* .tp-input's 0.88rem would otherwise win; the editor must read at exactly
       the size the paragraph does, or the box changes shape on Edit. */
    font-size: var(--fs-lg);
    min-height: 120px;
    resize: vertical;
}

.pm-summary-note {
    margin: 12px 0 0;
    font-size: var(--fs-xs);
    color: var(--text-light);
}

/* =========================================
   Carousels & Shop/Category Cards
   ========================================= */
.shop-card {
    border: 1px solid #eee;
    border-radius: var(--radius-md);
    padding: 10px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100px;
    background: white;
    gap: 8px;
    position: relative; /* anchor the favourite btn */
}

.shop-favourite-btn {
    position: absolute;
    top: 6px;
    right: 6px;
    cursor: pointer;
    line-height: 1;
    z-index: 1;
}

@media (hover: hover) {
    .shop-card:hover {
        border-color: #ccc;
        box-shadow: var(--shadow-xs);
        background-color: #f9f9f9;
    }
}

.shop-name {
    font-size: var(--fs-xs);
    font-weight: 500;
    color: var(--text-color);
    text-align: center;
    line-height: 1.1;
}

.category-card {
    height: 100px;
    background-size: cover;
    background-position: center;
    border-radius: var(--radius-md);
    position: relative;
    overflow: hidden;
    cursor: pointer;
}

.category-overlay {
    position: absolute;
    bottom: 0;
    left: 0;
    width: 100%;
    background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
    padding: 10px;
    display: flex;
    justify-content: space-between;
    align-items: flex-end;
}

.category-name {
    color: white;
    font-weight: 600;
    font-size: var(--fs-md);
}

/* Splide Arrows */
.splide__arrow {
    background: white;
    opacity: 0.7;
    transition: all 0.2s ease;
    box-shadow: var(--shadow-sm);
}

.splide__arrow:hover {
    background: var(--pink-highlight);
    opacity: 1;
}

.splide__arrow:hover svg,
.splide__arrow svg {
    fill: white;
    /* Force white always per css? */
}

/* Wait, style.css line 2681 says fill: white. But standard splide has arrows. */
.splide__arrow svg {
    fill: var(--text-color);
}

/* Reset to dark? No, style.css said white. */
.splide__arrow:hover svg {
    fill: white;
}

/* =========================================
   Forms & Inputs
   ========================================= */
/* ── Shared "field with trailing actions" shell (#141) ──────────────────────
   One flex pill carrying the border + control height; a borderless input fills
   it and action buttons / an .input-actions group sit at the right behind a
   divider. Backs <url-field> (.input-with-actions), <search-field> (.search-field
   — both the public-page pill and the .search-field--inline filter variant) and
   the bare landing-page search (.search-wrapper). Per-wrapper deltas (radius,
   inline, min-width, action inset) follow. */
.input-with-actions,
.search-wrapper,
.search-field {
    display: flex;
    align-items: center;
    /* A floor: this shell's text lives in a child <input>, and a pinned height
       plus the overflow:hidden below clips an inflated placeholder outright. */
    min-height: var(--control-height);
    box-sizing: border-box;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-control);
    overflow: hidden;
    background: var(--surface);
}
/* Wrappers that carry trailing action button(s) reserve a 4px right inset; the
   bare .search-wrapper (no buttons) doesn't. */
.input-with-actions,
.search-field { padding-right: 4px; }
/* Search pills are a touch rounder (8px) than the 6px form fields; the inline
   filter-toolbar variant (below) stays at the 6px form radius. */
.search-wrapper, .search-field { border-radius: var(--border-radius); }

.search-wrapper input, .search-field input {
    flex: 1;
    /* min-width:0 lets the flex item shrink below its intrinsic content size.
       Default flex min-width is `auto` which keeps the input as wide as its
       placeholder/value, pushing the submit button off the wrap at narrow
       viewports (mobile). */
    min-width: 0;
    align-self: stretch;
    border: none;
    height: auto;        /* defeat any control-height a class on the input sets */
    padding: 0 15px;
    outline: none;
    font-size: var(--fs-md);
}

/* ── <search-field> component (js/components/SearchField.js) ──────────────────
   The default look is a rounded pill with a clear-× + submit (magnifier) button,
   used by /browse, /mystuff, /list, /celebrity, /celebrities, /search.
   The shell, borderless input and in-flow .input-actions cluster all come from
   the shared fold above (#140/#141) — no per-component positioning. */
.search-field {
    margin-bottom: 8px;
}
.search-field > input[type="search"]::-webkit-search-cancel-button,
.search-field > input[type="search"]::-webkit-search-decoration,
.search-hero-input[type="search"]::-webkit-search-cancel-button,
.search-hero-input[type="search"]::-webkit-search-decoration {
    -webkit-appearance: none;
    appearance: none;
}
/* Inline filter-toolbar variant — the former <admin-search> look (merged #142):
   a compact 6px field sized to sit next to <select>s, clear-× only.
   width (not min-width) is deliberate: inline-flex shrink-wraps its content, so
   with only a min-width the clear-× button's width gets ADDED to the field when
   it appears — the field visibly widens as you type. A defined width gives the
   borderless flex input (flex:1; min-width:0) room to shrink for the button
   instead, so toggling the × never changes the field's footprint. */
.search-field--inline {
    display: inline-flex;
    width: 240px;
    max-width: 100%;
    border-radius: var(--radius-control);
    margin-bottom: 0;
}
.search-field--inline input { padding: 0 12px; font-size: var(--fs-md); }
/* Size: --md — drop the shell to the 32px md control (mirrors .btn-md) and scale
   the trailing action cluster to match, so a search field can sit in a denser
   column than the 38px default. A height tier ONLY, orthogonal to --inline
   (radius + density): the filter-panel search composes both. The smaller button
   shrinks the centered .input-actions group + its divider with it, keeping the
   clear-× proportional to the shorter field. */
/* The submit (SearchField.js): the hero's .btn-primary at .btn-sm — a 28px
   pink magnifier + "Search" inside the 38px pill (the wrapper's 4px right
   inset matching its top/bottom room). flex:none + nowrap: the input shrinks
   for it, the label never wraps under its icon. */
.search-field > .btn-primary { flex: none; white-space: nowrap; }
.search-field--md { min-height: var(--control-height-md); }
.search-field--md .input-action-btn { width: 24px; height: 24px; }
/* The search clear-× leaves the .input-actions group (SearchField.js); the
   divider stays with the magnifier. The circle itself is the shared clear-×
   (.input-action-btn--clear, below) — this only re-pins its size after the --md
   rule so both height tiers get the same circle. */
.search-field > .input-action-btn--clear {
    width: 22px;
    height: 22px;
    margin-right: 4px;
}

/* ── <search-field hero> — the large bar (.search-hero-input-wrap) ──────────
   The bordered 12px shell with a full-height pink Search button: /search's
   empty-state hero, the homepage hero, and (since 2026-09-25) the /search
   results bar and /browse product search. Lived in search.css with a copy in
   home.css until /browse needed it; WIDTH and outer spacing stay with each
   page (the /search hero caps at 640px, the homepage's .home-hero-search too),
   the in-page bars take .search-hero-bar. */
.search-hero-input-wrap {
    display: flex;
    gap: 8px;
    align-items: stretch;
    background: #fff;
    border: 1px solid var(--border-color);
    border-radius: 12px;
    padding: 6px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
    transition: box-shadow 0.15s ease, border-color 0.15s ease;
    /* Clip the button's straight-edge pink fill at the wrap's rounded
       corner — otherwise the button spills past the curve at narrow
       viewports (visible on mobile when the button is pressed against
       the right edge). */
    overflow: hidden;
}
/* The empty-query Search button stays full strength — the shared
   .btn-keep-color:disabled dims it to 0.55, which read as washed out here.
   Still disabled (not-allowed cursor, no submit). */
.search-hero-input-wrap .btn-primary.btn-keep-color:disabled { opacity: 1; }
.search-hero-input-wrap:focus-within {
    border-color: var(--pink-highlight);
    box-shadow: 0 2px 12px rgba(182, 3, 120, 0.12);
}
.search-hero-input {
    flex: 1;
    min-width: 0;
    border: none;
    background: transparent;
    /* --fs-base, not --fs-lg: the input has no height of its own, so its
       font-size sets the flex row's height, while the submit button next to it
       is pinned to --control-height. One step up and the row outgrows the
       button, which cannot stretch, and the button rides 3px high. */
    font-size: var(--fs-base);
    padding: 10px 14px;
    font-family: inherit;
    outline: none;
}
/* Clear-× inside the hero bar: the wrap is align-items:stretch, so a fixed-size
   icon button would pin to the top — center it against the tall submit button. */
.search-hero-clear { align-self: center; }
/* Barcode-scanner trigger at the bar's left end (<search-field scan>): the
   shared .input-action-btn, one size up to suit the taller hero row. */
.search-hero-scan { position: relative; align-self: center; width: 36px; height: 36px; margin-left: 2px; }
/* Divider between the scanner and the input — the same 1px --border-color
   rule the other search bars draw before their trailing actions
   (.input-actions), centred in the wrap's 8px gap (right: -4.5px = half the
   gap + half the line). A pseudo-element, so the button's own hover fill never
   covers it. */
.search-hero-scan::after {
    content: '';
    position: absolute;
    top: 4px;
    bottom: 4px;
    right: -4.5px;
    width: 1px;
    background: var(--border-color);
    pointer-events: none;
}
/* The large bar as a page's toolbar row (/search results, /browse): full
   column width, and the 8px gap under it the slim .search-field had. */
.search-hero-bar { margin-bottom: 8px; }
@media (prefers-reduced-motion: reduce) {
    .search-hero-input-wrap { transition: none; animation: none; }
}

/* ── Search-as-you-type dropdown (Phase 1) ─────────────────────────────────
   Shared by /search (hero + slim results bar) and the homepage hero input.
   The anchor is the positioned parent; the menu floats just under the bar.
   Lives here (not search.css) so every page carrying a <search-field> — /search
   AND index.html, which never loads search.css — styles the dropdown from the
   one shared sheet. */
.search-suggest-anchor {
    position: relative;
}
.search-suggest-menu {
    position: absolute;
    top: calc(100% + 6px);
    left: 0;
    right: 0;
    z-index: 50;
    margin: 0;
    padding: 6px;
    list-style: none;
    background: #fff;
    border: 1px solid var(--border-color);
    border-radius: 12px;
    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
    max-height: 360px;
    overflow-y: auto;
}
/* The hero menu anchors to the FORM (full hero width) so it escapes the input
   wrap's overflow:hidden clip — constrain + center it to match the 640px bar. */
.search-suggest-menu--hero {
    max-width: 640px;
    margin: 0 auto;
}
.search-suggest-item {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 9px 12px;
    border-radius: 8px;
    cursor: pointer;
    font-size: var(--fs-md);
    color: var(--text-color);
    transition: background 0.1s ease;
}
.search-suggest-item.is-active,
.search-suggest-item:hover {
    background: var(--pink-highlight-pale);
}
.search-suggest-phrase {
    flex: 1 1 auto;
    min-width: 0;
    text-align: left;   /* the hero is text-align:center; the full-width phrase must not inherit it */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
/* The matched (typed) portion of a suggestion, bolded via highlightMatch(). */
.search-suggest-phrase strong { font-weight: 700; color: var(--text-color); }
/* Per-row dismiss 'x': removes a personal recent search from history, or hides
   a server-suggested phrase (device-local blocklist). Hidden until row hover. */
.search-suggest-x {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    padding: 0;
    border: 0;
    /* Rounded-rectangular hover pill, matching the search field's clear-×
       (.input-action-btn--clear) in principle — same neutral pill, its own size. */
    border-radius: 4px;
    background: transparent;
    cursor: pointer;
    opacity: 0;
    transition: opacity 0.1s ease, background 0.1s ease;
}
.search-suggest-item:hover .search-suggest-x,
.search-suggest-item.is-active .search-suggest-x {
    opacity: 0.65;
}
.search-suggest-x:hover {
    opacity: 1;
    background: rgba(0, 0, 0, 0.06);
}

/* ── Products section (3rd dropdown layer) ─────────────────────────────────
   A labeled section of up to 3 live Meili hits, then the two counter lines.
   Section header + the two footer lines are non-interactive presentation rows;
   the product rows + "See all" reuse .search-suggest-item (hover/click) and add
   the two-line title/meta layout on top of it. Highlighted matches arrive as
   <em> from Meili's _formatted, rendered via v-html — styled below. */
.search-suggest-section {
    display: flex;
    align-items: center;
    gap: 7px;
    padding: 8px 12px 4px;
    margin-top: 4px;
    border-top: 1px solid var(--border-color);
    font-size: var(--fs-xs);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-light);
}
/* The hero menu's items are centered; the product rows must read left-aligned. */
.search-suggest-product {
    align-items: flex-start;
    text-align: left;
}
.search-suggest-product-text {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
}
.search-suggest-product-title {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.search-suggest-product-meta {
    display: flex;
    align-items: center;
    gap: 6px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: var(--fs-base);
    color: var(--text-light);
}
.search-suggest-product-dot { opacity: 0.6; }
/* Meili wraps matched terms in <em> inside _formatted — bold them, not italic. */
.search-suggest-product-title em,
.search-suggest-product-meta em {
    font-style: normal;
    font-weight: 700;
    color: var(--text-color);
}
.search-suggest-product-meta em { color: var(--text-light); }
/* Footer line 1: static "— and N more products" (non-interactive). */
.search-suggest-more {
    padding: 4px 12px 2px;
    text-align: left;
    font-size: var(--fs-base);
    color: var(--text-light);
}
/* Footer line 2: "See all N results" — a real action row (reuses .search-suggest-item). */
.search-suggest-seeall {
    text-align: left;
    font-weight: 600;
    color: var(--pink-highlight);
}

/* Shared base style for <select> elements — mirrors how buttons work, including
   the floor-plus-em-padding sizing (see .btn). */
.form-select {
    min-height: var(--control-height);
    box-sizing: border-box;
    padding: 0.3em 10px;
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius);
    font-family: inherit;
    font-size: var(--fs-md);
    color: var(--text-color);
    background: var(--surface);
    cursor: pointer;
    outline: none;
    appearance: auto;
}

/* .form-select with a leading icon and OUR chevron (sort dropdowns:
   SortFilterBar, FilterSortRow). The <select> stays native — phones keep the
   OS picker, keyboard and screen readers stay free — only its box is restyled:
   appearance:none drops the system arrow, the two <app-icon>s sit over the
   padding and let clicks through to the select. Colours follow .btn-neutral
   (surface, grey hover). */
.select-icon {
    position: relative;
    display: inline-flex;
    align-items: center;
    min-width: 0;
    color: var(--text-color);
}
.select-icon > .form-select {
    appearance: none;
    -webkit-appearance: none;
    width: 100%;
    min-width: 0;
    padding-left: 34px;
    padding-right: 32px;
    text-overflow: ellipsis;
    font-weight: 500;
    transition: background-color 0.15s ease, border-color 0.15s ease;
}
.select-icon > svg {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    pointer-events: none;
}
.select-icon-lead { left: 12px; }
.select-icon-chevron { right: 10px; color: var(--text-light); }
@media (hover: hover) {
    .select-icon > .form-select:hover { background-color: var(--bg-light); }
}
.select-icon > .form-select:focus-visible { border-color: var(--pink-highlight); }
@media (prefers-reduced-motion: reduce) {
    .select-icon > .form-select { transition: none; }
}

/* <sort-select> (SortSelect.js): the native .select-icon select on touch, our
   button + menu where there is a real mouse. The menu is the search-suggest
   dropdown's look (12px radius, soft shadow, pink-pale active row). */
.sort-select { position: relative; display: inline-flex; min-width: 0; }
.sort-select-native { display: inline-flex; width: 100%; }
.sort-select-custom { display: none; position: relative; min-width: 0; }
@media (hover: hover) and (pointer: fine) {
    .sort-select-native { display: none; }
    .sort-select-custom { display: inline-flex; }
}
.sort-select-btn {
    appearance: none;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    padding: 0.3em 10px 0.3em 12px;
    font-weight: 500;
    text-align: left;
    transition: background-color 0.15s ease, border-color 0.15s ease;
}
.sort-select-btn:hover { background-color: var(--bg-light); }
.sort-select-btn.is-open,
.sort-select-btn:focus-visible { border-color: var(--pink-highlight); }
.sort-select-btn > svg { flex-shrink: 0; }
.sort-select-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sort-select-chevron { color: var(--text-light); margin-left: 2px; transition: transform 0.2s ease; }
.sort-select-btn.is-open .sort-select-chevron { transform: rotate(180deg); }
.sort-select-menu {
    position: absolute;
    top: calc(100% + 6px);
    right: 0;
    z-index: 50;
    min-width: 100%;
    width: max-content;
    max-width: 280px;
    margin: 0;
    padding: 6px;
    list-style: none;
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: 12px;
    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
    outline: none;
    transform-origin: top right;
}
.sort-select-option {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 9px 12px;
    border-radius: 8px;
    cursor: pointer;
    font-size: var(--fs-md);
    color: var(--text-color);
    white-space: nowrap;
    transition: background-color 0.1s ease;
}
.sort-select-option.is-active { background: var(--pink-highlight-pale); }
.sort-select-option.is-selected { color: var(--pink-highlight); font-weight: 600; }
.sort-select-option-label { flex: 1 1 auto; }
.sort-select-check { flex-shrink: 0; color: var(--pink-highlight); }
.sort-select-menu-enter-active { transition: opacity 0.16s ease-out, transform 0.16s cubic-bezier(0.4, 0, 0.2, 1); }
.sort-select-menu-leave-active { transition: opacity 0.12s ease-in, transform 0.12s ease-in; }
.sort-select-menu-enter-from,
.sort-select-menu-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
@media (prefers-reduced-motion: reduce) {
    .sort-select-btn,
    .sort-select-chevron,
    .sort-select-option { transition: none; }
    .sort-select-menu-enter-active,
    .sort-select-menu-leave-active { transition: opacity 0.1s linear; }
    .sort-select-menu-enter-from,
    .sort-select-menu-leave-to { transform: none; }
}

.checkbox-container {
    display: flex;
    align-items: center;
    cursor: pointer;
    font-size: var(--fs-md);
    color: var(--text-light);
    gap: 8px;
}
/* Shrink-to-content variant: in a text-align:center column (the delete-list
   confirmation) a block-level flex label spans the width and strands the box at
   the far edge. Nothing else about .checkbox-container changes. */
.tp-inline-check { display: inline-flex; }

.checkbox-container input,
.filter-option input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;
    background-color: var(--surface);
    margin: 0;
    cursor: pointer;
    width: 18px;
    height: 18px;
    border: 1px solid #ccc;
    border-radius: var(--radius-sm);
    display: grid;
    place-content: center;
    transition: all 0.2s;
    position: relative;
}

.checkbox-container input::before,
.filter-option input[type="checkbox"]::before {
    content: "";
    width: 10px;
    height: 10px;
    transform: scale(0);
    transition: 0.2s;
    box-shadow: inset 1em 1em white;
    transform-origin: center;
    clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}

.checkbox-container input:checked,
.filter-option input[type="checkbox"]:checked {
    background-color: var(--pink-highlight);
    border-color: var(--pink-highlight);
}

.checkbox-container input:checked::before,
.filter-option input[type="checkbox"]:checked::before {
    transform: scale(1);
}

/* Notification matrix — rows = type, columns = In-app / Email. `display:contents`
   lets each head/row's cells drop straight into the shared 3-column grid. Shared
   by /profile (self notification settings) and /admin (per-user edit). */
.notif-matrix {
    display: grid;
    grid-template-columns: 1fr auto auto;
    align-items: center;
    gap: 14px 28px;
}
.notif-matrix-head,
.notif-matrix-row { display: contents; }
.notif-matrix-head span {
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-light);
    font-weight: 600;
    text-align: center;
    padding-bottom: 4px;
}
.notif-matrix-head span:first-child { text-align: left; }
.notif-matrix-label { font-size: var(--fs-md); color: var(--text-color); }
/* Each cell is a lone pink .checkbox-container (shared) — centre it in its column. */
.notif-matrix-row .checkbox-container { justify-self: center; }

/* =========================================
   Promos
   ========================================= */
.promo-banner {
    background-color: var(--bg-light);
    border-radius: var(--radius-md);
    padding: 24px;
    margin: 0 0 20px 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    text-align: center;
}

.promo-title {
    font-size: var(--fs-lg);
    color: var(--pink-highlight);
    margin-bottom: 0;
    line-height: 1.4;
    font-weight: 500;
}

.promo-actions {
    display: flex;
    flex-direction: column;
    gap: 15px;
    align-items: center;
    width: 100%;
}

.promo-image {
    display: none;
}

@media (min-width: 769px) {
    .promo-banner {
        display: grid;
        grid-template-columns: auto 1fr auto;
        column-gap: 20px;
        row-gap: 15px;
        text-align: center;
        padding: 20px;
        align-items: center;
    }

    .promo-image {
        grid-column: 1;
        grid-row: 1;
        display: block;
        height: 100px;
        width: auto;
        flex-shrink: 0;
        margin-left: -15px;
    }

    .promo-image img {
        height: 100%;
        width: auto;
        transform: scale(1.2);
    }

    .promo-title {
        grid-column: 2 / 4;
        grid-row: 1;
        align-self: center;
    }

    .promo-subtitle {
        grid-column: 1 / 3;
        grid-row: 2;
        text-align: left;
        padding-left: 10px;
    }

    .promo-actions {
        grid-column: 3;
        grid-row: 2;
        flex-direction: row;
        width: auto;
        justify-self: end;
    }
}

/* =========================================
   Modals
   ========================================= */
/* Notifications Overlay */
.notifications-overlay {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: var(--z-modal-backdrop);
    opacity: 0;
    transition: opacity 0.6s ease;
}

.notifications-overlay.active {
    display: block;
    animation: fadeIn 0.6s ease forwards;
}

/* Notifications Modal - Responsive */
/* Desktop / Default: Right Sidebar */
.notifications-modal {
    position: fixed;
    top: 0;
    right: -400px;
    width: 400px;
    max-width: 90%;
    height: 100%;
    background: var(--surface);
    box-shadow: -2px 0 10px rgba(0, 0, 0, 0.2);
    z-index: var(--z-modal);
    transition: right 0.4s cubic-bezier(0.4, 0, 0.2, 1);
    display: flex;
    flex-direction: column;
    border-radius: 0;
}

.notifications-modal.active {
    right: 0;
}

/* Mobile: Bottom Sheet */
@media (max-width: 768px) {
    .notifications-modal {
        top: auto;
        right: auto;
        bottom: -100%;
        left: 0;
        width: 100%;
        max-width: 100%;
        height: auto;
        min-height: 75vh;
        max-height: 90vh;
        box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
        transition: bottom 0.4s cubic-bezier(0.4, 0, 0.2, 1);
        border-radius: 20px 20px 0 0;
    }

    .notifications-modal.active {
        right: auto;
        bottom: 0;
    }
}



.notifications-content {
    flex: 1;
    overflow-y: auto;
    padding: 0;
}

.notifications-content .item {
    padding: 20px;
    border-bottom: 1px dashed var(--border-color);
    display: flex;
    gap: 15px;
}

.notifications-content .action-btn {
    display: inline-block;
    padding: 6px 15px;
    background-color: var(--green-highlight-dark);
    color: #fff;
    border-radius: var(--radius-md);
    text-decoration: none;
    font-size: var(--fs-base);
    margin-top: 5px;
}

/* ── Notification Items ─────────────────────────────────────────── */
.notif-state-msg {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    padding: 36px 20px;
    color: #aaa;
    font-size: var(--fs-md);
    text-align: center;
}
.notif-state-error { color: #c0392b; }

.notif-mark-all-row {
    display: flex;
    justify-content: flex-end;
    /* Extra bottom padding separates the row from the first notification below
       (the row only renders when unreadCount > 0, so the gap is conditional). */
    padding: 6px 12px 14px;
}
/* List notifications emphasise the list NAME in bold + uppercase
   (<strong> already provides the bold). */
.notif-emph-list { text-transform: uppercase; }
.notif-list { display: flex; flex-direction: column; position: relative; }

/* Smooth reorder when a card moves between unread/read (e.g. after a swipe):
   neighbours glide into place (move) while the card fades out of its old spot
   and the read section pushes down to receive it. */
.notif-list-move,
.notif-list-enter-active,
.notif-list-leave-active { transition: transform 0.28s ease, opacity 0.28s ease; }
.notif-list-enter-from,
.notif-list-leave-to     { opacity: 0; transform: translateY(-10px); }
/* Take the leaving card out of flow so siblings can animate up/down over it.
   Compound selector beats the later `.notif-swipe { position: relative }`. */
.notif-swipe.notif-list-leave-active { position: absolute; width: 100%; z-index: 0; }
@media (prefers-reduced-motion: reduce) {
    .notif-list-move,
    .notif-list-enter-active,
    .notif-list-leave-active { transition: none; }
}

.notif-item {
    display: flex;
    align-items: flex-start;
    gap: 12px;
    padding: 15px 14px;
    border-bottom: 1px solid var(--border-color);
    cursor: pointer;
    transition: background 0.15s, transform 0.2s ease;
    background: var(--surface);
    position: relative;   /* sits above the swipe action background */
    z-index: 1;
    touch-action: pan-y;  /* browser keeps vertical scroll; we own horizontal */
}
.notif-item:hover { background: #fafafa; }
.notif-item--unread { background: #fff5fb; border-left: 3px solid var(--pink-highlight); }
.notif-item--unread:hover { background: #fdeef8; }

/* ── Mobile swipe (Gmail-style) ──────────────────────────────────────
   .notif-swipe wraps each card; the action background is revealed as the
   opaque .notif-item slides horizontally over it. */
.notif-swipe {
    position: relative;
    overflow: hidden;
}
.notif-swipe-bg {
    position: absolute;
    inset: 0;
    z-index: 0;
    display: flex;
    align-items: center;
    font-size: var(--fs-sm);
    font-weight: 600;
}
.notif-swipe-bg--read   { background: var(--pill-green-bg); color: var(--pill-green-text); }
.notif-swipe-bg--unread { background: var(--pill-blue-bg);  color: var(--pill-blue-text); }
.notif-swipe-hint {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 0 18px;
}
.notif-swipe-hint--left  { margin-right: auto; }  /* revealed on right-swipe (left edge) */
.notif-swipe-hint--right { margin-left: auto; }   /* revealed on left-swipe (right edge) */

/* Icon bubble */
.notif-icon {
    flex-shrink: 0;
    width: 34px;
    height: 34px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
}
.notif-icon--down    { background: var(--pill-green-bg);  color: var(--pill-green-text); }
.notif-icon--up      { background: var(--pill-red-bg);    color: var(--pill-red-text); }
.notif-icon--target  { background: var(--pill-blue-bg);   color: var(--pill-blue-text); }
.notif-icon--percent { background: var(--pill-yellow-bg); color: var(--pill-yellow-text); }
.notif-icon--info    { background: var(--pill-purple-bg); color: var(--pill-purple-text); }

/* Product image */
.notif-image-wrap {
    flex-shrink: 0;
    width: 75px;
    height: 75px;
}
.notif-image {
    width: 75px;
    height: 75px;
    border-radius: var(--radius-control);
    /* Unlike every card surface, this renders the RAW shop origin —
       NotificationController sends products.image_url, not a cdn.eshops.bg
       250×250 — so a tall original (befit.bg ships 229×818) was genuinely
       cropped here by `cover`. Match the card convention: whole image, white
       --surface fill. */
    object-fit: contain;
    background: var(--surface);
    border: 1px solid var(--border-color);
    display: block;
}
/* A shop LOGO is artwork with no bleed of its own, so edge-to-edge it looks
   cramped against the tile border. Product photos keep filling the tile —
   this only applies to the logo variant (shop_followed). */
.notif-image--logo {
    padding: 10px;
    background: var(--surface);
}

.notif-image--placeholder {
    background: var(--surface-track);
    border-radius: var(--radius-control);
    border: 1px solid var(--border-color);
    display: flex;
    align-items: center;
    justify-content: center;
}

/* Body */
.notif-body { flex: 1; min-width: 0; }
/* List label — reuses the ProductCard list eyebrow
   (.product-list); links to the list page. Kill the default <a>
   underline (the eyebrow underlines the name on hover for affordance). */
.notif-list { margin-bottom: 3px; text-decoration: none; }
.notif-title {
    font-size: var(--fs-base);
    font-weight: 600;
    color: var(--pink-highlight);
    margin: 0 0 3px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.notif-message {
    font-size: var(--fs-base);
    color: #666;
    margin: 0 0 5px;
    line-height: 1.4;
}
.notif-footer {
    display: flex;
    align-items: center;
    gap: 10px;
}
.notif-time {
    font-size: var(--fs-xs);
    color: #aaa;
}
/* Shop (favicon + domain) in the card footer, next to the time-ago label. */
.notif-shop {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: var(--fs-xs);
    color: #aaa;
    min-width: 0;
}

/* Dismiss (×) button — sized for a comfortable tap target. */
.notif-dismiss {
    flex-shrink: 0;
    background: none;
    border: none;
    font-size: var(--fs-2xl);
    line-height: 1;
    color: #bbb;
    cursor: pointer;
    padding: 2px 6px;
    align-self: flex-start;
    transition: color 0.15s;
}
.notif-dismiss:hover { color: var(--pink-highlight); }

/* Divider between unread and read notifications */
.notif-divider {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px;
    color: var(--text-light);
    font-size: var(--fs-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
}
.notif-divider::before,
.notif-divider::after {
    content: '';
    flex: 1;
    height: 1px;
    background: var(--border-color);
}

/* Just Added Sidebar */
.hot-products-sidebar {
    /* Default / Mobile styles are overridden in media query below */
    z-index: var(--z-sidebar);
    background-color: var(--bg-light);

}

@media (min-width: 1100px) {
    .hot-products-sidebar {
        position: sticky;
        top: 76px;
        /* 56px header + 20px gap */
        width: 360px;
        /* Increased from 320px */
        min-width: 360px;
        flex-shrink: 0;
        height: calc(100vh - 100px);
        background-color: var(--bg-light);
        border: none;
        display: flex;
        flex-direction: column;
        overflow-y: hidden;
        padding-bottom: 20px;
        border-radius: var(--radius-md);

    }

    .hot-products-sidebar:hover .hot-products-content {
        overflow-y: auto;
        /* Scroll ONLY content on hover */
    }

    .hot-products-content {
        scrollbar-gutter: stable;
        /* Reserves space */
        /*padding-left: 20px !important;*/
        /* Reverted to 20px for card sizing */
        padding-right: 10px;
        /* Explicit right padding */
        box-sizing: border-box;
        /* Ensure padding shrinks content */
    }

    /* Custom scrollbar styling for hot products sidebar */
    .hot-products-content::-webkit-scrollbar {
        width: 6px;

    }

    .hot-products-content::-webkit-scrollbar-track {
        background: transparent;

    }

    .hot-products-content::-webkit-scrollbar-thumb {
        background: #ccc;
        border-radius: 3px;

    }

    .hot-products-content::-webkit-scrollbar-thumb:hover {
        background: #999;

    }




}

/* Mobile: Modal Style (Overrides Desktop Fixed style) */
@media (max-width: 1099px) {
    .hot-products-sidebar {
        position: fixed;
        top: 0;
        right: -400px;
        /* Hidden off-screen right */
        bottom: auto;
        height: 100%;
        width: 400px;
        /* Fixed width like notifications modal */
        max-width: 90%;
        /* Max width 90% */
        border-left: none;
        z-index: var(--z-modal-backdrop);
        /* Higher than notifications? */
        transition: right 0.4s cubic-bezier(0.4, 0, 0.2, 1);
        background: var(--surface);
        display: flex;
        box-shadow: -2px 0 10px rgba(0, 0, 0, 0.2);

    }

    /* Ensure content scrolls on mobile too */
    .hot-products-sidebar .hot-products-content {
        overflow-y: auto;

    }

    .hot-products-sidebar.active {
        right: 0;

    }


}

/* Header inside Sidebar */


/* Feed Content */
/* Feed Content */
.hot-products-content {
    flex: 1;
    padding: 10px;

}

/* Feed Item Card */
.hot-products-content .item {
    position: relative;
    background: var(--surface);
    border-radius: var(--radius-md);
    padding: 12px;
    margin-bottom: 10px;
    box-shadow: var(--shadow-xs);
    border: 1px solid var(--border-color);

}
/* Mobile (Hot Products bottom-sheet): extra top padding so the image, title
   and shop get more air above them. The bookmark is absolutely positioned
   (top:8px), so it stays pinned in the corner. */
@media (max-width: 1200px) {
    .hot-products-content .item { padding-top: 24px; }
}

.hot-products-content .header {
    display: flex;
    align-items: center;
    margin-bottom: 8px;
    gap: 8px;

}

.hot-products-content .avatar {
    width: 24px;
    height: 24px;
    border-radius: 50%;
    object-fit: cover;

}

.hot-products-content .username {
    font-size: var(--fs-sm);
    font-weight: 600;
    color: var(--text-color);

}

.hot-products-content .time {
    font-size: var(--fs-xs);
    color: var(--text-light);
    margin-left: auto;
    margin-left: 4px;
}

.hot-products-content .time::before {
    content: "\2022";
    margin-right: 4px;
    color: #ccc;

}

.hot-products-content .body {
    display: flex;
    gap: 10px;
    margin-bottom: 10px;

}

.hot-products-content .image {
    width: 84px;
    height: 84px;
    object-fit: contain;
    border-radius: var(--radius-sm);
    border: 1px solid var(--border-color);
    flex-shrink: 0;
    padding: 2px;
}

.hot-products-content .details {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    justify-content: flex-start;

}

.hot-products-content .title {
    font-size: var(--fs-base);
    font-weight: 400;
    color: var(--text-color);
    margin-bottom: 4px;
    line-height: 1.3;

}

/* Clickable title link */
.hot-products-content .hot-title-link {
    color: inherit;
    text-decoration: none;
}
.hot-products-content .hot-title-link:hover {
    color: var(--pink-highlight);
}

/* Hot card: the shop line is .product-shop-link + .shop-icon, exactly as
   ProductCard renders it — look AND behaviour. A long shop name wraps here
   just as it does in the central card; the 160px ellipsis this used to force
   was the compact layout disagreeing with the component for no reason a reader
   of either card could see.
   The one thing still local is the axis: .details is a flex column, so the
   inline-flex anchor would stretch across it, where in ProductCard's block
   context it shrink-wraps. align-self restores that — it is how the rule
   matches the component, not a departure from it. */
.hot-products-content .product-shop-link {
    align-self: flex-start;
}

/* Hot card: the price is .product-price-block > .product-price-pair, same as
   ProductCard — what is adopted is the one-line "price / struck" layout, not
   ProductCard's type scale. The compact tier keeps the --fs-sm the live price
   has always had here; unifying the size would have grown it to 15.36px in a
   ~270px sidebar column.
   The rest is layout that genuinely differs: the block's 48px reserve exists
   to keep Buy/Track level across a GRID row and has no job in a stacked feed,
   and space-between puts the drop badge at the right edge of a left-aligned
   column (ProductCard centres, its column being centred). */
.hot-products-content .product-price-block {
    min-height: 0;
    margin-bottom: 0;
    justify-content: space-between;
}

/* Desktop sidebar only: both halves of the pair at ONE size, the live price's
   own --fs-sm. This is the one place the compact card overrides
   .product-price's max(0.96em, …): that em is there so the price tracks the
   card holding it, but with the struck price INLINE beside it the two are read
   as one string and any step between them shows, and 15.36px is a lot of price
   for a ~300px column. An em against the 16px root and an absolute --fs-sm
   sibling cannot land on the same value, so both are stated. Tokens either
   way — still on the ladder.
   The mobile sheet is deliberately absent: it has ProductCard's room, so it
   keeps ProductCard's scale (15.36px live over a --fs-sm strike) untouched. */
@media (min-width: 1201px) {
    .hot-products-content .product-price,
    .hot-products-content .product-old-price {
        font-size: var(--fs-sm);
    }
}

/* Hot card: image wrapper holds the absolute discount bubble */
.hot-products-content .hot-image-wrap {
    position: relative;
    flex-shrink: 0;
    align-self: flex-start;
}

/* Hot card: image column — the photo container with the admin rating pill
   stacked directly beneath it (not overlapping the image). */
.hot-products-content .hot-image-col {
    display: flex;
    flex-direction: column;
    align-items: center;
    flex-shrink: 0;
    align-self: flex-start;
}

/* Hot card: admin-only rating debug pill, centered UNDER the photo container.
   Reuses .badge.badge-grey.badge-sm. */
.hot-products-content .hot-rating-wrap {
    margin-top: 4px;
}
.hot-products-content .hot-rating-pill {
    cursor: help;
}

/* Multi-line scoring formula tooltip for the admin rating pill — one term per
   .hot-rating-row. Appended to <body> (NOT scoped under .hot-products-content) and
   position:fixed so it escapes the Hot Products card/sidebar overflow:hidden and
   is never clipped; JS clamps it to the viewport. Mirrors the .admin-hint-popup
   look (light card, dark text, monospace numbers). Same escape pattern as
   .freshness-tooltip. */
.hot-rating-tip {
    position: fixed;
    left: 0;
    top: 0;
    display: none;
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-control);
    padding: 8px 11px;
    font-size: var(--fs-sm);
    line-height: 1.5;
    z-index: var(--z-tooltip);
    box-shadow: var(--shadow-md);
    /* One formula term per line when there is room (max-content), but never
       wider than the phone: a nowrap row used to push the box off-screen,
       where no left clamp could bring it back. */
    width: max-content;
    max-width: calc(100vw - 16px);
    overflow-wrap: anywhere;
    text-align: left;
    pointer-events: none;
}
.hot-rating-tip.visible { display: block; }
.hot-rating-tip .hot-rating-row {
    font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
    color: var(--text-color);
}

/* Hot card: smaller discount bubble sized for the compact thumbnail (reuses .discount-badge).
   Slight inset from the image container's corner so the bubble doesn't sit flush. */
.hot-products-content .discount-badge {
    top: 4px;
    right: 4px;
    /* 2.708em x 9.6px (--fs-3xs) is the 26px this has always been. Same reason
       as the base badge for using em: if the platform inflates this label, the
       circle inflates with it — "-18%" bold needs 31px once the label reaches
       14px, which a pinned 26px could never have held. */
    width: 2.708em;
    height: 2.708em;
    font-size: var(--fs-3xs);
    box-shadow: var(--shadow-xs);
}

/* Hot card: the "new" pill scaled to the 84px thumbnail, same inset and type
   scale as the discount bubble above so the two corners read as one set. */
.hot-products-content .product-card-new {
    bottom: 4px;
    right: 4px;
    font-size: var(--fs-3xs);
    padding: 1px 5px;
}

/* Hot card: bookmark (add to list) repositioned to the card's top-right */
.hot-products-content .icon-favorite {
    top: 8px;
    right: 8px;
    left: auto;
}

/* Hot card empty state (replaces a former inline style) */
.hot-products-content .hot-products-empty {
    color: #aaa;
    font-size: var(--fs-base);
    text-align: center;
    padding: 24px 0;
}

.hot-products-content .shop {
    color: var(--text-light);
    font-size: var(--fs-xs);
}

.hot-products-content .actions {
    display: flex;
    gap: 8px;
    align-items: stretch;
}
/* Both tiers: the pair splits one narrow row, so both buttons carry
   .btn-narrow — that class IS the tighter horizontal footprint, and nothing
   here needs to restate its padding. What is left is only how the row divides:
   equal halves, label centred.
   No min-height either: .btn-narrow keeps the base button's --control-height
   floor, which is 38px on a pointer and 44px on touch (core.css), so the pair
   is equal-height for free. The 32px this used to pin undercut both, and the
   one place that changed anything was the place it must not — a 32px touch
   target on the phone. */
.hot-products-content .actions > * {
    flex: 1;
    justify-content: center;
}

/* Mobile sheet: Buy goes solid, as in the central card — pale is the desktop
   sidebar's concession to a dense column, not this card's identity. Same tokens
   .btn-blue uses, restated because a colour class cannot be viewport-conditional
   in a static template.
   The :link/:visited/:not(:hover) twin is not optional: the anchor guard near
   the top of this file re-asserts each variant's colour across link states at
   (0,3,1), which outranks a plain descendant override — without it Buy came out
   blue on blue, label and icon invisible.
   The row itself does NOT change here: the pair stays side by side, and keeps
   .btn-narrow's horizontal metrics. Those are what carry ProductCard's wording
   at this width — the sheet is 320px capped at 85%, so ~262px of row on a 360px
   phone, ~127px a button, of which the base .btn's 24px sides and 8px gap would
   spend 56px against .btn-narrow's 32px. */
@media (max-width: 1200px) {
    /* Type back up to the base button's --fs-md, which is ProductCard's. Size
       only: .btn-narrow's 14px sides and 4px gap stay, and they are what buy the
       room for it — at 262px of row on a 360px phone the pair still measures
       127px a button. */
    .hot-products-content .actions > * {
        font-size: var(--fs-md);
    }

    .hot-products-content .actions .btn-blue-pale,
    .hot-products-content .actions a.btn-blue-pale:is(:link, :visited):not(:hover) {
        background-color: var(--blue-highlight);
        color: #fff;
        border-color: var(--blue-highlight);
    }
}

@media (max-width: 1200px) and (hover: hover) {
    .hot-products-content .actions .btn-blue-pale:hover {
        background-color: var(--blue-highlight-dark);
        color: #fff;
    }
}

/* The two action labels: both ship on every card, CSS shows one. The sheet
   takes ProductCard's full wording, the 360px desktop sidebar the compact pair,
   and a static template cannot pick by viewport.
   Not .desktop-only/.mobile-only (core.css): those switch at 1100px, which is
   not this component's boundary — the sidebar is still the 320px off-canvas
   sheet up to 1200px, so the utilities would hand the sheet the short label for
   a 100px band. */
.hot-btn-label-short {
    display: none;
}

@media (min-width: 1201px) {
    .hot-btn-label-full {
        display: none;
    }

    .hot-btn-label-short {
        display: inline;
    }
}

/* =========================================
   SIDEBARS (Shared: Celebrities & Just Added)
   Refactored/Restored from deleted files
   ========================================= */

/* --- Desktop Layout --- */
@media (min-width: 1201px) {

    /* Right Sidebar (Just Added) */
    .hot-products-sidebar {
        position: sticky;
        top: 76px;
        /* 56px header + 20px gap */
        width: 360px;
        min-width: 360px;
        flex-shrink: 0;
        height: calc(100vh - 100px);
        border-left: 1px solid var(--border-color);
        border: none;
        display: flex;
        flex-direction: column;
        overflow-y: hidden;
        background: transparent;
        padding-left: 10px;
    }

    /* Left Sidebar (Celebrities / Star Watch) */
    .celebrities-sidebar {
        position: sticky;
        top: 76px;
        width: 210px;
        min-width: 210px;
        flex-shrink: 0;
        height: calc(100vh - 100px);
        border: none;
        display: flex;
        flex-direction: column;
        overflow-y: hidden;
        background: transparent;
        padding-right: 10px;
    }

    .hot-products-sidebar:hover,
    .celebrities-sidebar:hover {
        overflow-y: auto;
    }

    #celebrities-list {
        display: block;
    }

    .product-title {
        font-size: var(--fs-lg);
    }

}

/* --- Mobile Layout (Off-Canvas) --- */
@media (max-width: 1200px) {

    /* Overlays */
    .celebrities-overlay {
        display: none;
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.5);
        z-index: var(--z-modal-backdrop);
        opacity: 0;
        transition: opacity 0.3s ease;
    }

    .celebrities-overlay.active {
        display: block;
        opacity: 1;
    }

    /* Sidebars */
    .hot-products-sidebar,
    .celebrities-sidebar {
        position: fixed;
        top: 0;
        height: 100%;
        background: var(--surface);
        z-index: var(--z-modal);
        display: flex;
        flex-direction: column;
        transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
        box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
        max-width: 85%;
        width: 320px;
    }

    /* Right Sidebar */
    .hot-products-sidebar {
        right: 0;
        transform: translateX(100%);
        border-left: 1px solid #eee;
    }

    .hot-products-sidebar.active {
        transform: translateX(0);
    }

    /* Left Sidebar */
    .celebrities-sidebar {
        left: 0;
        transform: translateX(-100%);
        border-right: 1px solid #eee;
    }

    .celebrities-sidebar.active {
        transform: translateX(0);
    }

    /* Headers inside sidebars */
    .celebrities-header {
        padding: 15px;
        border-bottom: 1px solid #eee;
        display: flex;
        justify-content: space-between;
        align-items: center;
    }

    .celebrities-content,
    .hot-products-content {
        flex: 1;
        overflow-y: auto;
        padding: 10px;
    }

    #celebrities-list {
        display: grid;
        grid-template-columns: repeat(2, 1fr);
        gap: 18px;
    }
}

/* --- Celebrity Cards --- */
#celebrities-list {
    list-style-type: none;
}

.celebrities-content .card {
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    padding: 10px;
    text-align: center;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    width: 100%;
    height: 100%;
    box-sizing: border-box;
    box-shadow: var(--shadow-xs);
}


.celebrities-content .card button {
    width: 100%;
}

.celebrities-content .avatar-wrapper {
    position: relative;
    width: 60px;
    height: 60px;
    margin-bottom: 8px;
}

.celebrities-content .avatar {
    width: 100%;
    height: 100%;
    border-radius: 50%;
    object-fit: cover;
}

/* ── Celebrity card (<celebrity-card>) ─────────────────────────────────────
   Shared star tile — the /stars directory grid AND homepage rails fed by a
   `celebrity` Selection. Moved here verbatim from pages/celebrities.css when
   the markup was extracted into the component; the rules are unchanged. */
/* minmax(0, 1fr), NOT 1fr: a bare `1fr` is `minmax(auto, 1fr)`, whose auto
   minimum is the item's MIN-CONTENT width. The card's widest atom is a
   nowrap button label plus the button's 24px side padding (e.g.
   "Продукти (132)" ≈ 170px), so the tracks refused to shrink below ~198px and
   a 2-up grid overflowed a 390px phone by 36px — the same width at 360, 390
   and 414, which is the tell that the track was content-sized, not
   viewport-sized. The 0 floor lets the track shrink and the card's own
   `min-width: 0` lets the card follow it. */
.celeb-grid-container {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 12px;
    /* Top gap only, as .shop-grid-container already does: the controls above
       carry no horizontal padding, so `padding: 10px` indented the cards
       against them. */
    margin-top: 10px;
}

@media (min-width: 601px) {
    .celeb-grid-container {
        grid-template-columns: repeat(3, minmax(0, 1fr));
    }
}

@media (min-width: 1201px) {
    .celeb-grid-container {
        grid-template-columns: repeat(4, minmax(0, 1fr));
    }
}

/* `appcol` is the /browse-family page column (declared in pages/browse.css);
   this grid also renders where it doesn't exist — homepage rails, admin
   showcase — and those keep the viewport rules above. Gated at 1100 because
   below it the column is the viewport; ungated it moved the 2→3 step to a
   580px viewport. Both branches present so the 4-across rule above can't reach
   into a narrow column (forced to 459px it drew four 104px cards). */
@media (min-width: 1100px) {
    @container appcol (max-width: 699px) {
        .celeb-grid-container {
            grid-template-columns: repeat(3, minmax(0, 1fr));
        }
    }

    @container appcol (min-width: 700px) {
        .celeb-grid-container {
            grid-template-columns: repeat(4, minmax(0, 1fr));
        }
    }
}

.celeb-card {
    background: #fff;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);   /* 8px — same card corner as .compact-card */
    /* Same resting elevation as the product .compact-card it shares the
       homepage rails with — the rails are white on white, and without it the
       star/list tiles read flatter than the product tiles beside them. */
    box-shadow: var(--shadow-sm);
    /* Roomy top and bottom so a star tile stands as tall as the list
       tile it sits beside on the homepage — measured, the two were 235 and 262
       and the mismatch read as a mistake in a stack of rails. */
    padding: 28px 12px 24px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;
    text-align: center;
    transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
    /* Containing block for the follow heart (top-left) and the "new" flag
       (top-right) — both are absolutely positioned overlays like ProductCard's
       bookmark. Without this they would escape to the nearest positioned
       ancestor, which on a Splide rail is the whole track. */
    position: relative;
    /* Opt out of the flex/grid `auto` minimum so the card can follow a track
       narrower than its widest button label (see .celeb-grid-container). */
    min-width: 0;
}

/* Follow heart — reuses ProductCard's .icon-favorite (absolute, top:0 left:0,
   pink fill, .icon-favorite-outline for the un-followed state). Nudged inside
   the card's rounded corner; ProductCard can sit flush at 0 because its image
   container is square. */
.celeb-card > .icon-favorite {
    top: 8px;
    left: 8px;
}

/* "New" flag — mirrors the heart across the card's top edge. Uses the universal
   .badge + .badge-blue, no bespoke pill. */
.celeb-card-new {
    position: absolute;
    top: 8px;
    right: 8px;
    z-index: 10;
    /* Decorative label — let clicks pass through to .celeb-card-link, so the
       corner it covers doesn't become a dead patch on an otherwise fully
       clickable card. */
    pointer-events: none;
    margin: 0;
    /* Sentence case, not caps. The stored strings ("вие", "нов", "ново", "nou")
       are lowercase in every language, so `capitalize` is what raises the first
       letter — these labels are single words, so it can't mis-title a phrase. */
    text-transform: capitalize;
    letter-spacing: 0.04em;
}

.celeb-card:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
    border-color: var(--pink-highlight);
}

/* Whole-card link — stretched across the card (which is already position:
   relative for the heart and the corner flag), so the entire tile leads to
   /{nickname}. A real <a>, not a JS click handler, so ctrl/middle-click,
   "open in new tab" and the browser's status-bar URL preview all behave.
   z-index:1 lifts it over the static text; the follow heart (z-index:10) and
   the action row below stay on top and keep their own click.
   It cannot be an ::after on .celeb-card-handle — that anchor has
   overflow:hidden for its ellipsis and would clip the pseudo-element. */
.celeb-card-link {
    position: absolute;
    inset: 0;
    z-index: 1;
}

/* ── Avatar ────────────────────────────────────────────────────────────── */
.celeb-card-avatar-wrap {
    display: block;
    position: relative;
    width: 64px;
    height: 64px;
    flex-shrink: 0;
    margin-bottom: 6px;
}

.celeb-avatar {
    width: 64px;
    height: 64px;
    border-radius: 50%;
    object-fit: cover;
    border: 2px solid var(--border-color);
    display: block;
}

/* ── Text ──────────────────────────────────────────────────────────────── */
/* The global a:hover in core.css (0,1,1) underlines every non-button link and
   out-ranks the text-decoration:none below (0,1,0). The handle is the card's
   heading, not running text, so it must not gain a rule on hover.
   .celeb-card-handle:hover is (0,2,0) and wins — no !important needed. Colour
   is left to the global rule. */
.celeb-card-handle:hover { text-decoration: none; }

/* The @handle is the prominent line, the real name the muted one under it —
   same order as the star profile header (/celebrity). */
.celeb-card-handle {
    font-size: var(--fs-md);
    font-weight: 700;
    color: var(--text-color);
    text-decoration: none;
    line-height: 1.2;
    width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Truncates like the line above it: a long real name would otherwise wrap and
   leave the tiles in a grid row at unequal heights. */
.celeb-card-name {
    font-size: var(--fs-base);
    color: var(--text-light);
    width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.celeb-card-followers {
    font-size: var(--fs-xs);
    color: var(--text-light);
}

/* ── Card action buttons row ────────────────────────────────────────────── */
/* ── Lists on a star card ────────────────────────────────────────────
   Replaces the old "Lists (N)" button: overlapping cover thumbs then the
   count. Same overlap idiom as .pm-stack on ProductCard (negative margin on the
   adjacent sibling), sized down to sit on a card action row. */
.celeb-lists {
    display: flex;
    align-items: center;
    /* Centred and wrap-as-a-unit: on the 2-column mobile grid the card is too
       narrow for stack + label on one line, and the label was breaking between
       the number and the noun ("3" / "колекции"). It now drops whole. */
    justify-content: center;
    flex-wrap: wrap;
    gap: 4px 8px;
    width: 100%;
    padding: 2px 0;
    color: var(--text-light);
    font-size: var(--fs-sm);
    text-decoration: none;
}
.celeb-lists-stack { display: inline-flex; align-items: center; flex-shrink: 0; }

.celeb-lists-thumb {
    width: 28px;
    height: 28px;
    border-radius: 50%;
    object-fit: cover;
    /* Grey ring: the discs overlap, so a white-on-white ring vanished against
       the card and neighbouring covers bled into each other. --border-color
       separates them AND outlines each one. */
    border: 2px solid var(--border-color);
    background: var(--grey-highlight-pale);
    flex-shrink: 0;
}
.celeb-lists-thumb + .celeb-lists-thumb { margin-left: -10px; }
.celeb-lists-thumb--empty { display: flex; align-items: center; justify-content: center; }

/* Size-up modifier for surfaces with room to spare — the owner's lists
   table (/myshops#lists), where the strip is the row's only picture of
   what is IN the list and 28px discs were too small to recognise a
   product by. Same proportional overlap as the card size. */
.celeb-lists-thumb--lg { width: 40px; height: 40px; }
.celeb-lists-thumb--lg + .celeb-lists-thumb--lg { margin-left: -14px; }

/* Third size step, for <list-card>: there the discs are the tile's HERO
   (they stand where the star card's 64px avatar stands), not a footnote on an
   action row, so they carry the same visual weight as that avatar. Same
   proportional overlap as the two sizes above. */
.celeb-lists-thumb--xl { width: 54px; height: 54px; }
.celeb-lists-thumb--xl + .celeb-lists-thumb--xl { margin-left: -18px; }

/* The owner line on a list card reuses .celeb-lists wholesale (it
   is already "round image + label, centred, wraps as a unit"). This adds only
   what that class cannot know: the row is a SECOND link inside a tile whose
   .celeb-card-link is stretched over everything, so it has to sit above it to
   keep its own click — exactly what .celeb-card-actions > * does. */
.list-card-owner {
    position: relative;
    z-index: 2;
    margin-top: 2px;
}

/* ── The card divider ──────────────────────────────────────────────────────
   A hairline above a card's closing row — the row that answers "who published
   this" (the list card's owner line) or "what is inside it" (the star
   card's lists preview). ONE rule with two selectors, so the same
   separation in both places cannot drift apart.

   It is a border on the row itself, NOT the .divider class: that one is the
   labelled "— OR —" separator with 20px margins, far too heavy inside a card,
   and a border needs no extra node.

   ⚠ Scoped to the ACTIONS row on the star side on purpose. .celeb-lists
   also renders in the /myshops lists table and in two Showcase specimens,
   which are table rows and swatches, not cards, and must not sprout a rule.

   .celeb-lists sets `padding: 2px 0` on the same element; both selectors
   here sit later in the file (and the star one is more specific), so the
   padding-top lands and the 2px bottom survives. */
.list-card-owner,
.celeb-card-actions > .celeb-lists {
    border-top: 1px solid var(--border-color);
    padding-top: 8px;
}

/* A list tile is .celeb-card + .list-card: the star card's geometry with
   the few things this card alone needs. Kept as a MODIFIER rather than folded
   into .celeb-card, because that class is also the real star tile on /stars —
   a taller list card must not silently restyle every star card. */
.list-card {
    /* ⚠ LESS padding than .celeb-card, which looks backwards until you count
       the lines: this card carries a type label the star card doesn't, worth
       ~19px, so it reaches the same height on less padding. The two are tuned
       together — the goal is equal HEIGHT on a homepage that stacks both, not
       equal padding. Re-measure both if either card gains or loses a line. */
    padding-top: 20px;
    padding-bottom: 16px;
}

/* Breathing room between the cover discs and the list's own name — the
   card's flex `gap` is tuned for the tight text lines below, which is too close
   when the thing above is a 54px image. */
.list-card .celeb-card-handle {
    margin-top: 10px;
}

/* A shop LOGO is a wordmark, not a portrait. The default `cover` fills the disc
   by cropping the sides off, which rendered "Gladen.bg" as "laden". `contain`
   fits the whole mark inside the circle instead, and the surface ground keeps a
   transparent PNG legible against the disc's grey. Star avatars ARE portraits
   and keep `cover`, which is what should crop to fill. */
.celeb-lists-thumb--contain {
    object-fit: contain;
    padding: 3px;
    background: var(--surface);
}

/* More lists than thumbs shown → fade the trailing edge, which suggests
   "continues" without spending a slot on a +N chip. */
.celeb-lists-stack.is-truncated {
    -webkit-mask-image: linear-gradient(to right, #000 55%, transparent 100%);
    mask-image: linear-gradient(to right, #000 55%, transparent 100%);
}

.celeb-lists-label { line-height: 1.2; white-space: nowrap; }

@media (hover: hover) {
    /* The global a:hover in core.css underlines any non-button link. */
    .celeb-lists:hover { text-decoration: none; }
    .celeb-lists:hover .celeb-lists-label { color: var(--pink-highlight); }
}

.celeb-card-actions {
    display: flex;
    flex-direction: column;
    gap: 6px;
    width: 100%;
    /* Pin the button block to the card's bottom edge so action rows line up
       across the grid — cards vary in height (name wraps to a 2nd line, or a
       celebrity has no follower count), and without this the buttons float up
       to hug the text. padding-top keeps the original breathing room above the
       buttons even on the tallest card, where the auto margin collapses to 0. */
    margin-top: auto;
    padding-top: 8px;
}

/* Standard-sized buttons — no size override; full-width stacked, icon + label.
   Each btn keeps its own padding/font from the universal button classes. */
.celeb-card-actions > * {
    width: 100%;
    min-width: 0;
    text-decoration: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    /* Above .celeb-card-link so these keep their own click. Applied to the
       children, not the row, so the gap between them still falls through to
       the whole-card link. */
    position: relative;
    z-index: 2;
}

/* Verified dot — small circle overlaying an avatar's bottom-right corner
   (celebrities page + celebrities modal). NOT a .badge — don't hijack that. */
.celeb-verified-dot {
    position: absolute;
    bottom: 0;
    right: 0;
    width: 20px;
    height: 20px;
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
}

.celebrities-content .info {
    margin-bottom: 10px;
    width: 100%;
}

.celebrities-content .nickname {
    font-size: var(--fs-base);
    font-weight: 600;
    color: var(--text-color);
    margin-bottom: 2px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.celebrities-content .fullname {
    font-size: var(--fs-xs);
    color: var(--text-light);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* ── Page header (universal) ───────────────────────────────────────────
   THE "title + counter pill (+ actions)" row above lists and sections —
   one base for what used to be .admin-page-header / .ms-head /
   .mystuff-page-header (folded 2026-06-12). Rules:
   - the trailing action block (.page-header-actions, a button, a search)
     pushes to the right; the counter pill (.badge-count) never does — it
     hugs the title;
   - content pages where a subtitle line follows use -tight;
   - title typography stays page-owned (e.g. .mystuff-page-title).
   Mobile wrap behavior for admin's button-heavy headers lives in
   admin.css's phone-tier block. */
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.page-header > :last-child:not(:first-child):not(.badge-count) { margin-left: auto; }
.page-header-tight { gap: 10px; margin-bottom: 6px; }
.page-header-actions { display: flex; flex-wrap: wrap; gap: 8px; }
/* Description line under a .page-header (compose with .text-muted): tightens to
   the title above, then spaces from the filters/table below. */
.admin-intro { margin: 4px 0 14px; }

/* ── Empty state (universal) ───────────────────────────────────────────
   Centered placeholder for an empty product/list view: a muted message (one or
   more <p>) usually followed by an action button/link. Shared across browse,
   search, celebrities, celebrity, list and mystuff (was the misnamed
   page-local .browse-empty-state / .search-empty). */
.empty-state {
    text-align: center;
    padding: 48px 20px;
    color: var(--text-light);
}
.empty-state p {
    font-size: var(--fs-lg);
    color: var(--text-light);
    margin: 0 0 16px;
}

/* =========================================
   Component Headers (Refactored from inline)
   ========================================= */
.component-header {
    padding: 15px;
    margin-bottom: 15px;
    border-bottom: 1px solid var(--border-color);
    display: flex;
    align-items: center;
    justify-content: flex-start;
}

/* Tighter section-header padding for list pages (mystuff/list/celebrity). */
.component-header-tight { padding: 14px 12px 8px; }

.component-header .header-text h3 {
    margin: 0;
    /* Relative to the header, not to the page: a component header is dropped
       into modals, panels and pages that set their own base. No floor guard
       needed — 1.1em only ever resolves above its parent. */
    font-size: 1.1em;
    font-weight: 600;
}

.component-header .header-text p {
    margin: 0;
    font-size: max(0.8em, var(--fs-3xs));
    color: var(--text-light);
}

.component-header .icon-pink {
    margin-right: 10px;
    color: var(--pink-highlight);
}

.component-header .header-inner-flex {
    display: flex;
    align-items: center;
}

.component-header .standard-close-btn { margin-left: auto; }

/* Hot Products feed: the first card's absolutely-positioned bookmark
   (.icon-favorite, z-index:10) sits at the card's top-left and, on desktop,
   bled UP over this section header. Give the header its own stacking context
   above the cards so it always paints on top. Opaque bg so nothing shows
   through. Scoped to the feed — other .component-header uses are untouched. */
.hot-products-content > .component-header {
    position: relative;
    z-index: 11;
    background: var(--bg-light);
}

/* ============================================================================
   Generic Modal Layout — THE modal anatomy (raw markup and Modal.js alike)
   ============================================================================
     .modal-overlay.active                      dims page; click = close
     .modal-wrapper                             the shell (flex column; hidden until .active)
           [.modal-dialog][.modal-dialog-sm|-wide]   centered + width tier
           [.modal-dialog-tall]                 tabbed dialogs: fixed frame
       .component-header.modal-header  title/close, never scrolls
       [.modal-tabs] [.modal-sub-tabs]           optional tab rows
       .modal-body                    THE scroll container
       .modal-footer                             actions, OUTSIDE the body

   Shell personalities (core.css): mobile bottom sheet (default, <1201px),
   desktop right sidebar (≥1201px), centered dialog (.modal-dialog opt-in;
   admin.css forces it at all widths). Geometry lives in core.css +
   admin.css's mirrored override — nowhere else, never inline.

   Z-INDEX LADDER (keep in sync — grep "z-index: 2" before adding a layer):
     2000 .modal-overlay        2001 shell
     2010/2011 stacked sub-modal overlay/shell (.modal-stacked, admin)
     2020/2021 stacked-on-stacked overlay/shell (.modal-stacked-top)
     2100 floating panels above stacked modals (multi-select dropdown)

   Scroll: document is locked while any overlay is active (core.css
   body:has rule); .modal-body contains overscroll.

   Motion (core.css): sheets slide up, sidebars slide right, dialogs
   dialogIn (rise + settle), overlays fadeIn on mount; blur(3px) backdrop
   on desktop only; everything off under prefers-reduced-motion. Sheets
   carry a ::before grabber handle + safe-area bottom padding (suppressed
   on desktop variants and in admin, which never renders sheets).

   Keyboard (js/lib/modal-keys.js, loaded on every modal page): Escape
   closes the topmost modal via its overlay's click handler; Tab is
   trapped inside the topmost shell. Same overlay contract as the scroll
   lock — new modals get all of this for free.
   ============================================================================ */

/* The shell itself (.modal-wrapper: display/flex toggle + geometry) lives in
   core.css, with the centered-dialog override mirrored in admin.css. */

/* Slot-owned anatomy inside the shared Modal.js frame: the single content
   root fills the dialog so its .modal-body / .modal-footer
   children behave exactly like the inline admin modals. */
.modal-frame {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;
}

/* Unified body padding — 15px everywhere, matching .component-header.
   Covers Modal.js contents (.modal-frame) and inline dialog markup alike;
   admin.css carries the same value for its dialogs. */
.modal-frame > .modal-body,
.modal-wrapper.modal-dialog .modal-body {
    padding: 15px;
}

/* ── Stacked sub-modal ─────────────────────────────────────────────────
   A modal opened FROM another modal stays a centered dialog at EVERY width
   (a sheet stacked on a sheet reads wrong) and never shows a grabber. The
   <modal> component takes a `stacked` prop that puts .modal-stacked on the
   overlay + shell THEMSELVES, so the dialog + 2010/2011 z-ladder survive the
   teleport to <body> — that's what makes the dim cover the viewport on mobile.
   Admin's inline modals use the ancestor form instead (.modal-stacked wrapper
   div); admin.css carries that variant. z-index 2010/2011 sits above the base
   modal (2001). Values mirror core.css's .modal-dialog standard. */
.modal-overlay.modal-stacked.active  { z-index: var(--z-modal-stacked-backdrop); }
.modal-wrapper.modal-stacked         { z-index: var(--z-modal-stacked); }
.modal-wrapper.modal-stacked .modal-grabber { display: none; }

/* A stacked modal opened on top of ANOTHER stacked modal (sheet → modal →
   sub-modal, e.g. /saved filters → Lists → Share). One rung higher so
   its dim covers the modal beneath, not just the page. Both classes co-occur
   on the element, equal specificity — this rule wins by coming later. */
.modal-overlay.modal-stacked-top.active { z-index: var(--z-modal-stacked-top-backdrop); }
.modal-wrapper.modal-stacked-top        { z-index: var(--z-modal-stacked-top); }
.modal-wrapper.modal-stacked.modal-dialog {
    top: 0; left: 0; right: 0; bottom: 0;
    margin: auto;
    width: 90%;
    max-width: 660px;
    height: fit-content;
    min-height: 160px;
    max-height: min(90dvh, 100dvh - 80px);
    border-radius: 16px;
    animation: dialogIn 0.18s ease-out;
    box-shadow: var(--shadow-lg);
    overflow: hidden;
}
.modal-wrapper.modal-stacked.modal-dialog.modal-dialog-wide { max-width: 840px; }

/* -tall means "one stable frame, body scrolls" (core.css). The stacked block
   above restates the plain .modal-dialog geometry including `height:fit-content`,
   and — same specificity, later file — that silently beat core's -tall height,
   so a stacked tabbed dialog grew to 90dvh and its long lists never scrolled.
   Restated here at 4 classes so -tall survives stacking. */
.modal-wrapper.modal-stacked.modal-dialog.modal-dialog-tall { height: min(600px, 85dvh); }
@media (max-width: 1200px) {
    .modal-wrapper.modal-stacked.modal-dialog.modal-dialog-tall { min-height: min(550px, 85dvh); }
}

/* Opt-outs: the filter sheet's .filter-panel is the desktop left column
   and carries its own 15px side padding; the notifications list rows
   bring their own spacing; the overflow-actions sheet is a .menu-item list,
   which is edge-to-edge by design and carries its own 12px/20px padding. */
.modal-frame > .modal-body.filter-sheet,
.modal-frame > .modal-body.overflow-actions-sheet,
.modal-frame > .modal-body.notifications-wrapper {
    padding: 0;
}

/* ── Modal close button ───────────────────────────────────────────── */
.standard-close-btn {
    background: none;
    border: none;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 4px;
    border-radius: var(--radius-control);
    flex-shrink: 0;
    color: var(--text-light);
    transition: color 0.2s, transform 0.2s ease;
}

.standard-close-btn:hover {
    color: var(--text-color);
    transform: rotate(90deg);
}

/* No-title modal/sheet (Modal.js v-else): there's no header row for the close
   to sit in, so it floats in the top-right corner over the content. Scoped to
   .modal-close — the titled path keeps the in-flow header close. */
.modal-close {
    position: absolute;
    top: 15px;
    right: 15px;
    /* Above an in-slot .modal-header (also z-index:10): a no-title <modal>
       whose slot supplies its own header would otherwise paint the header
       over this floating close button, swallowing the click. */
    z-index: 11;
}

.modal-header {
    flex-shrink: 0;
    z-index: 10;
    /* Secondary drag handle for sheets: own the vertical gesture so a drag
       from the header follows instead of being eaten by the browser's pan.
       Harmless on desktop dialogs (headers never scroll). */
    touch-action: none;
}

.modal-body {
    flex: 1;
    overflow-y: auto;
    overflow-x: hidden;
    /* min-height:0 is the flexbox overflow fix (a flex child refuses to
       shrink below its content without it). The shell's min/max-height
       owns the modal's size — the body never forces it taller. */
    min-height: 0;
    position: relative;
    scrollbar-gutter: stable;
    /* Hitting the end of the modal scroll must not scroll the page */
    overscroll-behavior: contain;
}

/* =========================================
   Loading Animation
   ========================================= */

.loading-container {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 60px 20px;
    gap: 16px;
}

.loading-spinner {
    width: 48px;
    height: 48px;
    border: 4px solid var(--border-color);
    border-top-color: var(--pink-highlight);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
}

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

.loading-container p {
    color: var(--text-light);
    font-size: var(--fs-md);
    margin: 0;
}

.load-more-container {
    display: flex;
    justify-content: center;
    gap: 12px;   /* inert with one child; spaces the url-search CTA pair */
    padding: 30px 0;
}



/* ── Modal body / form primitives (shared across modals) ─────────────── */
/* .tp-modal-body is a bare marker — body padding comes from the unified
   .modal-body rules (see the modal standard block). */
.tp-field       { margin-bottom: 16px; }
.tp-label       { display: block; font-size: var(--fs-xs); font-weight: 600; color: #666; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
.tp-input       { width: 100%; padding: 0 12px; border: 1.5px solid #e0e0e0; border-radius: 10px; font-family: inherit; font-size: var(--fs-md); color: var(--text-color); background: #fafafa; outline: none; box-sizing: border-box; }
/* The fixed control height belongs to the SINGLE-LINE controls only. A
   textarea.tp-input was being squashed to 40px, so its `rows` attribute did
   nothing and a pasted embed snippet showed one line. Same split .form-control
   already uses (input/select get the floor; the textarea keeps block padding
   and sizes from rows). */
input.tp-input, select.tp-input { min-height: 40px; padding: 0.3em 12px; }
textarea.tp-input { height: auto; padding: 10px 12px; line-height: 1.45; resize: vertical; }
.tp-input:focus { border-color: var(--pink-highlight); background: var(--surface); }
.tp-error       { font-size: var(--fs-base); color: #c0392b; margin: 8px 0 0; text-align: center; }

/* ── ColorPicker (<color-picker>): swatch + hex + in-page HSV popover ── */
.cp { position: relative; }
.cp-row { display: flex; align-items: center; gap: 8px; }
.cp-swatch { flex: none; width: 40px; height: 40px; padding: 0; border: 1.5px solid #e0e0e0; border-radius: 10px; cursor: pointer; }
.cp-hex { flex: 1; min-width: 0; text-transform: lowercase; }
/* position:fixed + JS placement (ColorPicker.positionPop) so it escapes the
   modal's overflow clip and is always fully on-screen and tappable. */
.cp-pop { position: fixed; z-index: var(--z-popover); width: 220px; max-width: 84vw; padding: 10px;
    background: var(--surface); border: 1px solid var(--border-color); border-radius: 10px; box-shadow: var(--shadow-lg); }
/* Preset swatch row (system palette) above the HSV box */
.cp-presets { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-bottom: 10px; }
.cp-preset { aspect-ratio: 1 / 1; padding: 0; border: 1px solid rgba(0, 0, 0, 0.15); border-radius: 6px; cursor: pointer; }
.cp-preset.active { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--text-light); }
/* Compact variant: swatch-only square for sitting inline next to an input;
   the hex field moves into the popover footer. */
.cp--compact .cp-swatch { width: 28px; height: 28px; border-radius: 8px; }
.cp-swatch--auto { border-style: dashed; border-color: #bbb; }   /* empty = theme default */
.cp-pop-label { font-size: var(--fs-xs); font-weight: 600; color: var(--text-light); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 8px; }
.cp-pop-foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
/* Tighter padding with the lower floor, so 32px stays the drawn height. */
.cp-pop-foot .cp-hex { min-height: 32px; padding: 0.15em 12px; }
.cp-default-btn { flex: none; }
.cp-sv { position: relative; width: 100%; height: 140px; border-radius: var(--radius-control); overflow: hidden; cursor: crosshair; touch-action: none; }
.cp-sv-white { position: absolute; inset: 0; background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0)); }
.cp-sv-black { position: absolute; inset: 0; background: linear-gradient(to top, #000, rgba(0, 0, 0, 0)); }
.cp-thumb { position: absolute; width: 14px; height: 14px; border: 2px solid #fff; border-radius: 50%;
    transform: translate(-50%, -50%); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35); pointer-events: none; }
.cp-hue { position: relative; height: 14px; margin-top: 10px; border-radius: 7px; cursor: pointer; touch-action: none;
    background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); }
.cp-hue-thumb { position: absolute; top: 50%; width: 16px; height: 16px; border: 2px solid #fff; border-radius: 50%;
    transform: translate(-50%, -50%); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35); pointer-events: none; }
/* Alert form column (track-price modal only — scoped under .track-price-dialog,
   set in modalConfigs.js). Full-width on the mobile bottom-sheet so the
   Expiry-period pills + stepper have room (a 60% column squeezed the 3 month
   pills past the track and they overflowed). The 60%-centred look is
   desktop-only, where the centered .modal-dialog gives the form ample width —
   matches the ≥1201px breakpoint at which the shell stops being a full-width
   sheet (core.css). */
.track-price-dialog .tp-alert-form { width: 100%; margin: 0 auto; }
/* Mobile/tablet: the explainer is hidden outright rather than stacked. The
   sheet is already a long scroll on a phone, and a block of help text pushed
   below the form would sit under the fold where nobody reads it. */
.track-price-dialog /* Post-track confirmation line — "tracking is on, we filed it here". Sits
   above the list chips in the track-price modal's follow-up step. */
.tp-tracked-note {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0 0 14px;
    font-size: var(--fs-md);
    font-weight: 600;
    color: var(--text-color);
}
/* Hidden only inside the track-price dialog below the two-column breakpoint:
   there the panel IS the second column, and there is no room for it while the
   dialog is still a full-width sheet. Everywhere else (the star dashboard) it
   is a plain stacked block, so it must not be display:none by default. */
.track-price-dialog .explainer-panel { display: none; }
@media (min-width: 1201px) {
    /* Desktop only — the dialog stops being a full-width sheet at this
       breakpoint (core.css), which is exactly when a second column fits.
       2fr/1fr rather than the old 60%-centred form: the explainer takes the
       third that used to be empty gutter, so the form itself barely moves. */
    .track-price-dialog .tp-alert-layout {
        display: grid;
        grid-template-columns: 2fr 1fr;
        align-items: start;
        gap: 22px;
    }
    /* .tp-summary takes the same first column: manage-tracking swaps one for
       the other, and the modal must not change shape when Edit is pressed. */
    .track-price-dialog .tp-alert-form,
    .track-price-dialog .tp-summary { width: auto; margin: 0; min-width: 0; }
    .track-price-dialog .explainer-panel  { display: block; min-width: 0; }
}
/* Pale-blue explainer panel — SHARED, not track-price-only: the track-price
   dialog and the star stats dashboard both mount it, hence the unprefixed
   name. Only the display rules above are scoped to the dialog. Tokens only —
   --blue-highlight-pale is the same wash the homepage USP icons sit on. */
/* NB: -explainer-, NOT -hint. `.tp-hint` / `.tp-hint-text` are ALREADY taken
   in the track-price modal by the inline help tooltip next to "Target price"
   (an absolutely-positioned, opacity:0-until-hover bubble defined further
   down). Reusing those names hid this panel's text and restyled the tooltip. */
/* ── Alert-saved confirmation ────────────────────────────────────────────
   Shown between a successful save and whatever the flow does next. Shaped like
   .product-card-success-overlay (translucent sheet, centred column) but it
   covers a modal frame rather than a card, so it is its own rule rather than
   that class stretched over a second context.
   .tp-frame carries the positioning context: .modal-frame is shared by every
   modal and giving it `position: relative` globally would re-parent any
   absolutely-positioned descendant in all of them. */
.tp-frame { position: relative; }
.tp-confirm {
    position: absolute;
    inset: 0;
    background: rgba(255, 255, 255, 0.92);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 12px;
    z-index: 60;   /* over the footer, which the form's own controls sit in */
    animation: fadeIn 0.15s ease;
}
/* Swings from the crown, where a real bell hangs — rotating about the centre
   reads as a wobble, not a ring. */
.tp-confirm-bell {
    color: var(--pink-highlight);
    transform-origin: 50% 12%;
    animation: bellRing 0.9s ease-in-out;
}
.tp-confirm-text {
    margin: 0;
    font-size: var(--fs-md);
    font-weight: 600;
    color: var(--text-color);
}
/* Alert-removed confirmation. A removal is not an achievement, so this one
   SETTLES instead of celebrating: it eases down to rest, no swing and no
   confetti, in muted grey rather than the brand pink the other two use. */
.tp-confirm-off {
    color: var(--text-light);
    animation: confirmSettle 0.4s ease-out both;
}
@keyframes confirmSettle {
    from { transform: scale(1.18); opacity: 0; }
    to   { transform: scale(1);    opacity: 1; }
}

/* Saved-to-list confirmation: the bookmark FILLS rather than swapping to a
   solid icon. Two copies of the same AppIcon path stack exactly, and a
   clip-path wipes the solid one in from the bottom. Sized by the icons
   themselves — no width/height here to drift from the size= attribute. */
.tp-confirm-mark { position: relative; display: inline-flex; color: var(--pink-highlight); }
.tp-confirm-mark-outline { opacity: 0.3; }
.tp-confirm-mark-fill {
    position: absolute;
    inset: 0;
    animation: markFill 0.5s 0.1s ease-out both;
}
/* Six pieces thrown from the centre. Each carries its own vector in --cx/--cy
   so one keyframe serves all of them; the alternating colours are the two
   brand highlights, not a new palette. */
.tp-confetti { position: absolute; inset: 0; pointer-events: none; }
.tp-confetti > span {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 6px;
    height: 6px;
    border-radius: 1px;
    background: var(--pink-highlight);
    opacity: 0;
    animation: confettiPop 0.75s 0.25s ease-out both;
}
.tp-confetti > span:nth-child(2n) { background: var(--blue-highlight); }
.tp-confetti > span:nth-child(1) { --cx: -42px; --cy: -30px; }
.tp-confetti > span:nth-child(2) { --cx:   0px; --cy: -46px; }
.tp-confetti > span:nth-child(3) { --cx:  42px; --cy: -30px; }
.tp-confetti > span:nth-child(4) { --cx: -38px; --cy:  26px; }
.tp-confetti > span:nth-child(5) { --cx:   4px; --cy:  44px; }
.tp-confetti > span:nth-child(6) { --cx:  38px; --cy:  26px; }
@keyframes markFill {
    from { clip-path: inset(100% 0 0 0); }
    to   { clip-path: inset(0 0 0 0); }
}
@keyframes confettiPop {
    0%   { transform: translate(-50%, -50%) scale(0.4) rotate(0deg); opacity: 0; }
    25%  { opacity: 1; }
    100% { transform: translate(calc(-50% + var(--cx)), calc(-50% + var(--cy))) scale(1) rotate(160deg); opacity: 0; }
}

@keyframes bellRing {
    0%   { transform: rotate(0deg); }
    12%  { transform: rotate(18deg); }
    26%  { transform: rotate(-15deg); }
    40%  { transform: rotate(12deg); }
    54%  { transform: rotate(-9deg); }
    68%  { transform: rotate(6deg); }
    82%  { transform: rotate(-3deg); }
    100% { transform: rotate(0deg); }
}

.explainer-panel {
    background: var(--blue-highlight-pale);
    border-radius: var(--radius-lg);
    padding: 14px 16px;
}
.explainer-panel-title {
    margin: 0 0 8px;
    font-size: var(--fs-sm);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--blue-highlight);
}
.explainer-panel-text {
    margin: 0;
    font-size: var(--fs-base);
    line-height: 1.5;
    color: var(--text-color);
}
/* A panel heading a centred column (the sign-up modal: centred provider
   buttons and footer line) centres its title and text with it. */
.explainer-panel--centered { text-align: center; }
/* ── Modal footer ───────────────────────────────────────────────────────
   STANDARD: the footer sits OUTSIDE .modal-body as the shell's
   last flex child, so it can never scroll away with the content.
   Default look = right-aligned, natural-width buttons; dialogs that want
   full-width buttons add .modal-footer-stretch.
   BUTTON ORDER: the primary / forward action (Save, Next, Continue,
   Create, Activate) is ALWAYS the rightmost button; Cancel, secondary and
   destructive actions sit to its left.
   Modal.js contents follow the same rule via .modal-frame (see above) —
   no footer ever renders inside .modal-body. */
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; padding: 15px; border-top: 1px solid #f0f0f0; flex-shrink: 0; }
.modal-footer-stretch > button, .modal-footer-stretch > a { flex: 1; }

/* Sheet tier: tighten the footer buttons' horizontal footprint so a two-word
   label fits on one line in the cramped sheet row. Horizontal ONLY — this is
   NOT .btn-narrow, which also drops the label to --fs-sm; the size stays
   --fs-md and the 0.3em vertical padding + --control-height floor are
   untouched, so the box-grows-with-its-text guard still holds.
   Keyed to the existing 1200/1201 boundary — the modal's own sheet→dialog
   threshold (core.css) — so it adds no new breakpoint. .modal-stacked is
   excluded: it is a centered dialog at every width, not a sheet. */
@media (max-width: 1200px) {
    .modal-wrapper:not(.modal-stacked) .modal-footer > button,
    .modal-wrapper:not(.modal-stacked) .modal-footer > a {
        gap: 4px;
        padding-left: 14px;
        padding-right: 14px;
    }
}

/* Balancing "Close" — desktop dialog only. Measured: the modal is a full-width
   sheet up to 1200px and a centered 660px dialog from 1201px, so 1201 is the
   modal's own sheet→dialog threshold, not a guess. On the sheet the footer is
   full-bleed anyway and the sheet already has its own ✕ plus drag-to-dismiss,
   so a second button would only crowd the row; in the dialog a lone action
   stretched across the whole footer looked lopsided. */
.modal-footer-close-desktop { display: none; }
@media (min-width: 1201px) {
    .modal-footer-close-desktop { display: inline-flex; }
}

/* ── "You are leaving eShops" confirmation modal (.lvm-*) ──────────────────
   Body of the interstitial shown when a Buy click is intercepted for a shop
   with shops.transition_page on (js/lib/leaving-modal.js + LeavingContent.js).

   The modal twin of public/css/pages/leaving.css, which dresses the same
   content on the standalone /go/{hash} page. Two files on purpose: the page
   owns a whole document (its own body, its own card chrome), this owns only
   what sits inside the shared modal shell — there is no overlap to fold
   together. Colours, radii, buttons, badges and the ENTIRE product preview
   come from core.css / components.css; everything below is the little the
   card and the shell do not already supply.
   ───────────────────────────────────────────────────────────────────────── */
/* Body order is ad → product → notice. The notice sits last so it is the last
   thing read before the CTA directly under it. */
.lvm-lead {
    margin: 14px 0 0;
    font-size: var(--fs-base);
    line-height: 1.5;
    color: var(--text-color);
}

/* ── Ad slot, first in the body ── */
.lvm-ad-slot { margin-bottom: 16px; }

/* Paid placements have to be identifiable as such, and AdSense wants any label
   OUTSIDE the unit, saying only "Advertising" — so this is a sibling of the
   box, never inside it. Muted and small: it labels the slot, it is not a
   heading competing with the product. */
.lvm-ad-label {
    display: block;
    text-align: center;
    margin-bottom: 5px;
    font-size: var(--fs-3xs);
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--text-placeholder);
}

/* The one genuinely modal-specific thing: image BESIDE text. The card stacks
   them, so nothing in the card's own rules produces this row. */
.lvm-product {
    display: flex;
    gap: 12px;
    align-items: flex-start;
    padding: 10px;
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius);
}

/* The card's container is width:100% inside its own column; here it is one
   half of a row, so it shrinks to the image and drops the stacking gap. */
.lvm-product .product-image-container {
    flex: 0 0 auto;
    width: auto;
    margin-bottom: 0;
}

/* 84px, well under the card's 140. The preview only has to confirm "yes, that
   product" — the ad now leads the body, so the summary is a check, not the
   subject, and a smaller thumbnail also leaves the title a wider column to
   wrap in — which still holds on the bottom-sheet tier, the narrow one now
   that the dialog itself is 840px. */
.lvm-product .product-img { width: 84px; }

/* The card reserves 48px so Buy/Track line up across a grid row; there is no
   grid row here and the reservation is dead space. justify-content likewise —
   the card centres the pair because its column is centred, while this column
   is flush left, where a centred pair reads as an arbitrary indent. */
.lvm-product .product-price-block {
    margin-bottom: 0;
    min-height: 0;
    justify-content: flex-start;
}

/* A preview, not a card: 2 lines identify the product the visitor just clicked
   without letting a 250-char shop title drive the modal's height. Fixed, not
   responsive — the card varies its clamp by width because its column narrows,
   while this row keeps the same shape at every breakpoint, and a modal that
   changes height between them is the thing this clamp exists to stop.
   The full string stays reachable via the title attribute, as on the card. */
.lvm-product .product-title {
    margin-bottom: 4px;
    -webkit-line-clamp: 2;
    line-clamp: 2;
}

.lvm-info { min-width: 0; flex: 1; }

.lvm-shop {
    display: flex;
    align-items: center;
    gap: 6px;
    flex-wrap: wrap;
    font-size: var(--fs-sm);
    color: var(--text-light);
    margin-bottom: 8px;
}

.lvm-favicon { border-radius: 3px; display: block; }

/* Ad slot — the AdSense medium rectangle at its exact size, so enabling the
   unit drops it into a box already the right shape and nothing moves. The
   caption is ::after rather than markup so it can never disagree with the
   size actually reserved at this breakpoint; .lvm-ad-live drops it when real
   ad code is switched on. */
.lvm-ad {
    width: 336px;
    height: 280px;
    max-width: 100%;
    margin: 0 auto;
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--fs-xs);
    color: var(--text-placeholder);
}

.lvm-ad::after      { content: "336 × 280"; }

/* Live state drops BOTH the caption and the outline. The border is not
   cosmetic here: `* { box-sizing: border-box }` makes 336px the OUTER width, so
   a 1px border leaves the creative 334×278 — two pixels short of the unit the
   box promises, which clips a medium rectangle. With ads on, the outline has
   also done its job (it exists to make an empty placement visible in the
   layout) and a grey frame around a real ad is not wanted anyway. */
.lvm-ad-live        { border: none; }
.lvm-ad-live::after { content: none; }

/* ── Ad creative mount (js/components/AdSlot.js) ──────────────────────────
   The element the pasted tag is injected into. It fills its box exactly and
   adds nothing of its own: an ad unit is sold at a fixed pixel size, and any
   padding, border or line-height here would make the delivered creative
   smaller than the size the box promises. Shared by every slot, so it is
   deliberately outside the .lvm-* block — the modal and the /go page mount
   into the same thing.

   Sizing lives on the BOX (.lvm-ad / .lv-ad), never here and never in the
   component: the box is what reserves layout space before any ad exists, so
   it has to own the geometry on its own. */
.ad-slot-mount {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
}

/* Sandboxed creatives run in a null-origin iframe. Default iframe chrome (a
   2px inset border and inline-element baseline gap) would show as a line
   inside the box, so both are removed. */
.ad-slot-frame {
    width: 100%;
    height: 100%;
    display: block;
    border: 0;
}

/* ── Countdown gate ───────────────────────────────────────────────────────
   The Buy-now button is inert for config('eshops.transition_page
   .countdown_seconds') so the ad slot gets a minimum dwell. This row sits
   between the body and the footer, so it reads as a condition on the button
   directly beneath it rather than as body content. */
.lvm-countdown {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 9px;
    /* NO top padding, 15px bottom. The row sits between .modal-body and
       .modal-footer, so the space above it is already the body's own 15px
       bottom padding — adding more on top of that is what made it sit high and
       flush against the footer's divider. Matching 15 below gives the row the
       same gutter on both sides, on the shell's existing 15px rhythm. */
    padding: 0 15px 15px;
    font-size: var(--fs-sm);
    color: var(--text-light);
}

.lvm-ring {
    position: relative;
    flex: 0 0 auto;
    width: 32px;
    height: 32px;
    display: block;
}

/* -90deg so the sweep starts at 12 o'clock; without it the ring drains from
   3 o'clock and reads as if it began a quarter turn in. */
.lvm-ring svg { display: block; transform: rotate(-90deg); }

.lvm-ring-track { stroke: var(--border-color); }

.lvm-ring-run {
    /* --blue-highlight, the same token .btn-blue is painted with: the ring is
       a progress bar FOR that button, so it reads as belonging to it rather
       than as a second accent competing with it. */
    stroke: var(--blue-highlight);
    /* Matches the 200ms tick in LeavingContent, so the arc glides between
       whole seconds instead of stepping. linear, not ease: a countdown that
       accelerates or slows misreports the time left. */
    transition: stroke-dashoffset 0.2s linear;
}

.lvm-ring-num {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--fs-xs);
    font-weight: 600;
    font-variant-numeric: tabular-nums;   /* no width jitter 10 → 9 */
    color: var(--text-color);
}

/* The whole point is that the button is SEEN but not yet live, so it keeps its
   colour and its place — only the affordance is withdrawn. not-allowed rather
   than pointer-events:none so hovering still says "this is a button, and it
   isn't ready", instead of behaving like dead space. The click itself is
   stopped in JS (onContinue), which is what actually holds the gate. */
.btn-blue.is-locked {
    opacity: 0.5;
    cursor: not-allowed;
}

@media (prefers-reduced-motion: reduce) {
    .lvm-ring-run { transition: none; }
}

/* Below the 336px unit + the shell's 15px body padding either side, the
   medium rectangle no longer fits — step down to the 300×250 unit rather
   than let a 336px box overflow or squash. */
@media (max-width: 400px) {
    .lvm-ad             { width: 300px; height: 250px; }
    .lvm-ad::after      { content: "300 × 250"; }
    .lvm-product        { flex-direction: column; align-items: center; text-align: center; }
    .lvm-info           { text-align: center; }
    .lvm-shop,
    .lvm-product .product-price-block { justify-content: center; }
}

/* ── List selector (product header in track-price modal) ───────── */
.tp-list-wrap   { flex-shrink: 0; text-align: right; }
.tp-list-select { min-height: 32px; padding: 0.15em 8px; border: 1.5px solid #e0e0e0; border-radius: var(--radius-md); font-family: inherit; font-size: var(--fs-base); color: var(--text-color); background: #fafafa; outline: none; max-width: 180px; cursor: pointer; }
.tp-list-select:focus    { border-color: var(--pink-highlight); }
.tp-list-select:disabled { opacity: 0.65; cursor: not-allowed; }
/* Segmented toggle: the universal .toggle-pills.full (see the toggle-pills
   block below). */
/* List chips: the universal .chip.chip-lg system (see the badge/chip
   block near the top of this file). */
/* ── Track Price Modal: +/- stepper ─────────────────────────────────── */
/* gap shrinks before anything else does, so the -/+ buttons slide outward
   to make room for a longer number rather than squeezing it. */
/* BASELINE, not centre. Three type sizes share this row — the display figure,
   the unit stepped down from it, and the quieter conversion — and centring each
   one in the row's height set them on three different lines, so the eye read
   them as three separate readings. On a common baseline they read as one:
   "34.00 € / −5.0%". Baseline rather than flex-end because the boxes differ in
   height as well as in size; flex-end would line up their descender boxes,
   which is not where the eye sees the line. */
.tp-stepper { display: flex; align-items: baseline; justify-content: center; gap: 0.4em; padding: 8px 0 16px; flex-wrap: nowrap; min-width: 0; }
/* em: 1.774em x 24.8px (--fs-2xl) is the same 44px circle, and the +/− glyph now
   scales the button with it rather than out of it. */
.tp-stepper-val { font-size: var(--fs-4xl); font-weight: 700; color: var(--text-color); min-width: 64px; text-align: center; line-height: 1; font-variant-numeric: tabular-nums; }
/* em, not px: the display figure beside it steps down a tier on long values
   (--sm / --xs), and a fixed 3px gap grew optically wider as the digits shrank.
   0.12em holds the same optical space at every tier. */
.tp-stepper-unit { font-size: var(--fs-xl); color: #aaa; font-weight: 400; margin-left: 0.12em; }
/* The same target said in the other unit (% ⇄ price). Deliberately quiet and
   well below the display figure: it is a reading of the number being set, not a
   second thing to set, and at similar weight the two competed for the eye.
   Sits in .tp-stepper's baseline flex row, so it shares the display figure's
   line and the group stays centred as either figure changes width. */
.tp-stepper-alt { font-size: var(--fs-md); color: var(--text-light); font-weight: 500; white-space: nowrap; }
/* Editable big number. The value is an <input> in BOTH alert modes so it can be
   typed as well as stepped; the unit rides beside it, sized down, so "%" and a
   currency code both sit on the same baseline as the digits. */
/* Owns the DISPLAY SIZE for everything that makes up the figure — the "−", the
   digits, and nothing else. The tier used to sit on the input alone, which was
   fine only while the sign could not appear at a stepped-down size; now that one
   tier covers every mode (see tierLen) it can, and a full-size minus beside a
   shrunk number is worse than either. One declaration, both children inherit.
   The unit sets its own size and is deliberately unaffected. */
.tp-stepper-field { display: inline-flex; align-items: baseline; justify-content: center; min-width: 0; font-size: var(--fs-4xl); }
/* Long values step the whole figure down so a five-digit price still fits the
   dialog at 360px. ch units scale with font-size, so valueWidth follows. */
.tp-stepper-field--sm { font-size: var(--fs-3xl); }
.tp-stepper-field--xs { font-size: var(--fs-2xl); }
.tp-stepper-input {
    /* width comes from the component (valueWidth, in ch) so the field tracks
       its content in every browser — field-sizing:content is Chromium-only and
       would have left Safari/Firefox clipping the number. */
    min-width: 2ch;
    border: none;
    background: transparent;
    outline: none;
    padding: 0;
    font-family: inherit;
    font-size: inherit;          /* .tp-stepper-field owns the tier */
    font-weight: 700;
    line-height: 1;
    color: var(--text-color);
    text-align: center;
    /* Fixed-advance digits. The component already reserves a constant number of
       characters, but proportional figures still differ in ink width ("1" is
       narrower than "8"), so a centred number shimmered as it stepped past
       them. Tabular figures make every digit the same width, which is what the
       -/+ buttons need to hold still. */
    font-variant-numeric: tabular-nums;
}
.tp-stepper-input::placeholder { color: #ccc; font-weight: 700; }
/* The "−" in front of a percentage drop. Inherits the field's size so it can
   never disagree with the digits it belongs to — it is one number, not a symbol
   beside one. No margin: the glyph carries its own sidebearing, and anything
   added here reads as "minus, five" rather than "minus five". */
.tp-stepper-sign { font-size: inherit; font-weight: 700; line-height: 1; color: var(--text-color); font-variant-numeric: tabular-nums; }
/* Native spinners would cramp the display-size digits (and the slider below
   is the stepping control now). */
.tp-stepper-input::-webkit-outer-spin-button,
.tp-stepper-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.tp-stepper-input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
/* ── Inert value row ("Any price drop") ───────────────────────────────────
   That mode carries no figure, but its row stays on screen instead of being
   removed — pulling it out reflowed the form on every mode change and hid the
   fact that a value is still remembered. Dimmed, not hidden: the same
   opacity + not-allowed vocabulary every other disabled control here uses
   (.chip, .multi-select-trigger, .filter-price-input).
   The controls inside carry a real `disabled` attribute, so this is only the
   visual half — the browser already owns the tab order and the announcement. */
.tp-field.is-inactive .tp-label,
.tp-field.is-inactive .tp-stepper { opacity: 0.45; }
.tp-field.is-inactive .tp-stepper-input { cursor: not-allowed; }
/* The hint's help bubble is the one thing that stays live: "why this price?"
   is still worth reading while deciding whether to leave this mode. */
.tp-field.is-inactive .tp-hint { opacity: 1; }

/* Spacing for the single-handle reuse (TrackPriceContent). .tp-stepper's 16px
   bottom padding was the gap between the number and the field below it; with a
   track now sitting in between, that padding lands in the wrong place and left
   the slider crowding the NEXT field's label. Move the room to under the track. */
.tp-stepper:has(+ .filter-price-track-wrapper) { padding-bottom: 0; }
.tp-field .filter-price-track-wrapper { margin-bottom: 14px; }

/* The single-handle reuse of the price-filter slider (TrackPriceContent) has a
   disabled state the two-handle original never needed. Grey the fill and the
   thumb rather than fading the whole track — a 45%-opacity pink still reads as
   an active brand colour. */
.filter-range-input:disabled { cursor: not-allowed; }
.filter-range-input:disabled::-webkit-slider-thumb { background: #c4c4c4; cursor: not-allowed; }
.filter-range-input:disabled::-moz-range-thumb { background: #c4c4c4; cursor: not-allowed; }
.filter-price-track-wrapper:has(.filter-range-input:disabled) .filter-price-fill { background: #d8d8d8; }
/* ── Tracking info bubble (edit-alert mode in TrackPriceContent) ─────── */
.tp-info-bubble          { background: #fdf5fb; border: 1px solid #ecd9e8; border-radius: 10px; padding: 12px 14px; margin-bottom: 16px; display: flex; gap: 10px; align-items: flex-start; }
.tp-info-bubble-icon     { color: var(--pink-highlight); flex-shrink: 0; margin-top: 1px; }
.tp-info-bubble-body     { flex: 1; }
.tp-info-bubble-row      { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; }
.tp-info-stat            { display: flex; flex-direction: column; align-items: center; }
.tp-info-stat-val        { font-size: var(--fs-md); font-weight: 600; color: var(--text-color); }
.tp-info-stat-val.up     { color: #e74c3c; }
.tp-info-stat-val.down   { color: #27ae60; }
.tp-info-stat-lbl        { font-size: var(--fs-xs); color: #999; }
/* Narrow screens: keep ALL stats on one row — share the width instead of
   wrapping one stat onto its own line (labels may wrap inside their column). */
@media (max-width: 600px) {
    .tp-info-bubble-row  { flex-wrap: nowrap; gap: 8px; justify-content: space-between; }
    .tp-info-stat        { flex: 1 1 0; min-width: 0; }
    .tp-info-stat-val    { font-size: var(--fs-base); }
    .tp-info-stat-lbl    { text-align: center; }
}
/* ── Manage-tracking summary (TrackPriceContent, before "Edit") ──────────
   The alert as saved, read-only: label left, value right, one row each. Rules
   rather than a box — the pale .tp-info-bubble is the "here is what changed
   since you started" surface and shows alongside the FORM; this states the
   settings themselves, so it wants the plainer register of a definition list.
   Type tokens are borrowed wholesale from the pair it sits between:
   .tp-label's uppercase micro-caps for the term, .tp-info-stat-val's weight
   and size for the figure, so all three states of this modal read as one. */
.tp-summary       { margin: 0 0 4px; }
.tp-summary-row   { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border-color); }
.tp-summary-row:first-child { padding-top: 0; }
.tp-summary-row:last-child  { border-bottom: 0; }
.tp-summary-lbl   { margin: 0; font-size: var(--fs-xs); font-weight: 600; color: #666; text-transform: uppercase; letter-spacing: 0.04em; }
.tp-summary-val   { margin: 0; font-size: var(--fs-md); font-weight: 600; color: var(--text-color); text-align: right; }
/* The conversion rides with the figure, not under it — one target written two
   ways, the same relationship .tp-stepper-alt draws in the form. */
.tp-summary-alt   { margin-left: 5px; font-size: var(--fs-base); font-weight: 400; color: var(--text-light); }
/* Green only once the alert has actually fired: this is the one row that can
   carry news, and it reads as news only if the other three never do. */
.tp-summary-val.is-reached { color: var(--green-highlight); }

/* ── The inline hint, site AND admin — hover (desktop) / tap (touch) ─────── */
.tp-hint { position: relative; display: inline-flex; vertical-align: middle; margin-left: 5px; color: var(--text-color); cursor: help; }
@media (hover: hover) { .tp-hint:hover { color: var(--pink-highlight); } }
/* Content holder only: api.js shows this text in the <body>-level #hint-tip
   (.hint-tip above). An in-place absolute bubble was clipped by the modal body
   and ran off the right edge of a phone — and even hidden, it widened the page
   into a sideways scroll. */
.tp-hint-text { display: none; }
/* ─────────────────────────────────────────────────────────────────────── */

/* =========================================
   Filter Panel (browse.html desktop left column)
   ========================================= */
/* List list — name + count rows, used by list.html's left column and
   the ManageLists modal (moved here from mystuff.css 2026-06-01). */
.list-list {
    padding: 4px 8px 12px;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.list-item {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 7px 8px;
    border-radius: var(--radius-md);
    text-decoration: none;
    color: var(--text-color);
    font-size: var(--fs-md);
    font-weight: 500;
    transition: background 0.15s;
}

a.list-item:hover {
    background: var(--pink-highlight-pale);
    text-decoration: none;
    color: var(--text-color);
}

.list-icon {
    width: 20px;
    height: 20px;
    border-radius: 3px;
    flex-shrink: 0;
}

.list-name {
    /* Absorb the free space so trailing items (count badge, action buttons)
       sit at the right edge structurally — no margin-left:auto per element.
       min-width:0 lets the name actually shrink, so the ellipsis works. */
    flex: 1;
    min-width: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.list-name.clickable { cursor: pointer; }
@media (hover: hover) {
    .list-name.clickable:hover { color: var(--pink-highlight); }
}

/* Trailing action cluster in a row (edit/share buttons + count). */
.list-actions {
    display: flex;
    align-items: center;
    gap: 5px;
    flex-shrink: 0;
}

/* Active / selected shop item */
.list-item.active {
    background: var(--pink-highlight-pale);
    color: var(--pink-highlight);
    font-weight: 600;
}

.list-item.active:hover {
    color: var(--pink-highlight);
}

.filter-panel {
    display: flex;
    flex-direction: column;
    /* Match the padding of the right column's component-header */
    padding: 0 15px 15px;
    overflow-x: hidden; /* never allow horizontal scroll inside the panel */
}

.filter-panel-empty {
    /* Placeholder when shop grid is shown — keeps layout stable */
}

/* Panel header: title + reset button — matches .component-header style */
.filter-panel-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 15px 0 12px;     /* top matches component-header's 15px padding */
    border-bottom: 1px solid var(--border-color);
    margin-bottom: 4px;
}

.filter-panel-title {
    display: inline-flex;
    align-items: center;
    gap: 8px;                  /* icon ↔ label */
    font-weight: 600;
    font-size: 1.1em;          /* matches .component-header h3 */
    color: var(--text-color);
}

/* Individual filter section */
.filter-section {
    padding: 14px 0;
    border-bottom: 1px solid var(--border-color);
}
.filter-section:last-child {
    border-bottom: none;
}

.filter-section-label {
    font-weight: 600;
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.07em;
    color: var(--text-light);
    margin-bottom: 10px;
}

/* Collapsible sections (opt-in via the panel's `collapsible` prop): the label
   becomes a full-width click target with a chevron that flips when folded. */
.filter-section-label.filter-section-toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    cursor: pointer;
    user-select: none;
}
/* Left group of a section label — avatar + title + inline action (e.g. the
   "My lists" facet's user avatar and edit button), kept on one line so the
   chevron stays pinned right by the label's space-between. */
.filter-section-title {
    display: inline-flex;
    align-items: center;
    gap: 6px;
}
.filter-section-chevron {
    color: var(--text-light);
    transition: transform 0.18s ease;
}
.filter-section.is-collapsed .filter-section-chevron {
    transform: rotate(-90deg);
}
/* No body below the label when folded — drop the label's gap so the row is tight. */
.filter-section.is-collapsed .filter-section-label {
    margin-bottom: 0;
}

/* ── Category breadcrumb ── */
.filter-breadcrumb {
    display: flex;
    align-items: center;
    gap: 5px;
    margin-bottom: 10px;
    font-size: var(--fs-base);
    flex-wrap: wrap;
}

.filter-crumb-back {
    color: var(--pink-highlight);
    cursor: pointer;
    text-decoration: underline;
    text-underline-offset: 2px;
}
.filter-crumb-back:hover {
    color: var(--btn-primary-hover);
}

.filter-crumb-sep {
    color: var(--text-light);
}

.filter-crumb-current,
.filter-crumb-seg.filter-crumb-current {
    font-weight: 600;
    color: var(--pink-highlight);
    cursor: pointer;
}

/* Intermediate breadcrumb segments act like "back" links */
.filter-crumb-seg.filter-crumb-back {
    color: var(--pink-highlight);
    cursor: pointer;
    text-decoration: underline;
    text-underline-offset: 2px;
}
.filter-crumb-seg.filter-crumb-back:hover {
    color: var(--btn-primary-hover);
}

/* ── Bubbles (category / brand) ── */
.filter-bubbles {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
}

/* Filter bubbles are plain .chip toggles (button.chip); this modifier only adds
   the bubble-specific bits: truncate long category/brand names so they never
   stretch past the panel. */
.chip-bubble {
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
}

.chip-bubble-arrow {
    font-size: var(--fs-3xs);
    opacity: 0.65;
}

/* ── Entity facet option rows (List / Shop / Brand) ────────────────────
   Replaces the chip bubbles for the three entity pickers. Each row is a
   <label> = avatar tile · name · live count · checkbox; the whole row toggles
   the native checkbox (keyboard-focusable, multi-select). The avatar is one
   rounded-rect: a single-letter monogram (List / Brand), a shop favicon,
   or a dashed placeholder when a shop has no favicon. The count is the muted
   .badge-count; the checkbox reuses the styled box from .checkbox-container. */
.filter-options { display: flex; flex-direction: column; gap: 1px; }

.filter-option {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 5px 6px;
    border-radius: var(--radius-md);
    cursor: pointer;
    transition: background 0.12s;
}
.filter-option.active { background: var(--pink-highlight-pale); }
/* Hover only on real pointers — on touch :hover latches after a tap and makes
   the row look selected when it isn't. */
@media (hover: hover) {
    .filter-option:hover { background: var(--pink-highlight-pale); }
}

/* Favicon-sized (16px) so monograms and favicons read as one uniform glyph. */
.filter-avatar {
    flex: 0 0 16px;
    width: 16px;
    height: 16px;
    border-radius: var(--radius-sm);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    font-size: var(--fs-3xs);
    font-weight: 700;
    line-height: 1;
    text-transform: uppercase;
}
.filter-avatar-monogram { background: var(--pink-highlight-pale); color: var(--pink-highlight); }
.filter-avatar-favicon  { background: transparent; }
.filter-avatar-favicon img { width: 16px; height: 16px; object-fit: contain; }
.filter-avatar-empty    { border: 1px dashed var(--border-color); background: transparent; }
/* A glyph instead of a monogram or a favicon — /browse's shop-category facet,
   where every option already has an icon and an initial would be redundant.
   Transparent like -favicon; the icon carries its own colour. */
.filter-avatar-icon     { background: transparent; color: var(--text-light); }

.filter-option-name {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: var(--fs-md);
    color: var(--text-color);
}
.filter-option.active .filter-option-name { font-weight: 600; color: var(--pink-highlight); }
.filter-option .badge-count { flex: 0 0 auto; }
.filter-option input[type="checkbox"] { flex: 0 0 auto; }

/* ── Facet loading skeletons ─────────────────────────────────────────────────
   Placeholders for the Category / Brand sections while the shop's facet request
   is in flight (FilterPanel's `filtersLoading` prop). Those sections render off
   `.length > 0`, so before this an empty list and a list that had not arrived
   yet looked the same — the section was simply absent and the panel read as
   "this shop has no filters" for as long as the request took. Shaped to match
   what replaces them (.chip bubbles, .filter-option rows) so nothing jumps when
   the real data lands. */
.filter-skeleton {
    position: relative;
    overflow: hidden;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
}
/* A light band sweeping left→right, the standard "content is coming" cue.
   ::after rather than a background-position animation so the base colour stays
   a plain token. */
.filter-skeleton::after {
    content: '';
    position: absolute;
    inset: 0;
    transform: translateX(-100%);
    background: linear-gradient(90deg, transparent, var(--surface), transparent);
    animation: filter-skeleton-sweep 1.3s ease-in-out infinite;
}
@keyframes filter-skeleton-sweep {
    to { transform: translateX(100%); }
}
/* Motion here is decoration, not information — the placeholders still read as
   pending without it. */
@media (prefers-reduced-motion: reduce) {
    .filter-skeleton::after { animation: none; }
}

/* Chip-shaped, matching .chip's 24px min-height and --radius-lg. Widths cycle
   per position so the block reads as a list of names of different lengths
   rather than a bar chart — nth-child keeps that out of the template, which
   would otherwise need an inline :style per bubble. */
.filter-skeleton-chip {
    height: 24px;
    border-radius: var(--radius-lg);
}
.filter-skeleton-chip:nth-child(5n+1) { width: 84px; }
.filter-skeleton-chip:nth-child(5n+2) { width: 120px; }
.filter-skeleton-chip:nth-child(5n+3) { width: 66px; }
.filter-skeleton-chip:nth-child(5n+4) { width: 102px; }
.filter-skeleton-chip:nth-child(5n+5) { width: 74px; }
/* In the active-filter chip row the placeholder stands in for an ACTIVE chip
   (a ?shop=domain deep link waiting for the shop's name), on the white page.
   The panel's grey skeleton was #f8f9fa with a white sweep there: ~1:1 on
   white, so it read as an empty outline. Take the active chip's own pale pink
   and pink border, sweep a visible pink tint across it, and start mid-cycle so
   the band is already moving on a sub-second load. */
.filter-chips-row .filter-skeleton-chip {
    background: var(--pink-highlight-pale);
    border-color: var(--pink-highlight);
}
.filter-chips-row .filter-skeleton-chip::after {
    background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--pink-highlight) 18%, transparent), transparent);
    animation-delay: -0.5s;
}

/* Row-shaped — same geometry as .filter-option (avatar tile + name), so the
   Brand list keeps its height across the swap. */
.filter-skeleton-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 5px 6px;
}
.filter-skeleton-avatar {
    flex: 0 0 16px;
    height: 16px;
    border-radius: var(--radius-sm);
}
.filter-skeleton-line {
    height: 10px;
    border-radius: var(--radius-sm);
}
.filter-skeleton-row:nth-child(3n+1) .filter-skeleton-line { width: 60%; }
.filter-skeleton-row:nth-child(3n+2) .filter-skeleton-line { width: 45%; }
.filter-skeleton-row:nth-child(3n+3) .filter-skeleton-line { width: 72%; }

/* The per-section filter search sits full width, directly above its option
   list. Height is NOT set here — it comes from the composable .search-field--md
   size modifier on the field itself, so this stays a pure layout override. */
.filter-section .search-field--inline {
    width: 100%;
    margin-bottom: 8px;
}

/* Brand search is now the shared <search-field> (FilterPanel.js) — full-width
   default pill with a clear-×, no submit. Its old bespoke rules (incl. the 20px
   radius) are retired in favour of the component's standard look. */

/* "No brands match" empty state */
.filter-no-results {
    font-size: var(--fs-base);
    color: var(--text-light);
    padding: 4px 2px;
}

/* Show more / less link */
.filter-show-more { margin-top: 8px; }
.filter-show-more-link {
    font-size: var(--fs-base);
    color: var(--pink-highlight);
    cursor: pointer;
    text-decoration: underline;
    text-underline-offset: 2px;
}
.filter-show-more-link:hover { color: var(--btn-primary-hover); }

/* ── Discount tier bubbles ── */
.filter-tier-bubbles {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin-top: 0;
}

/* ── Dual-handle price slider ── */
.filter-price-slider {
    display: flex;
    flex-direction: column;
    gap: 14px;
}

/* ── Offer distribution behind the track ──────────────────────────────────
   How many offers sit at each price, so the shopper can see where the market
   actually is before dragging. Counts come from the same Meili facet request
   that produces the track (app/Support/PriceHistogram.php), so the chart costs
   no extra query.

   .filter-price-chart exists only to keep the bars and the track together:
   .filter-price-slider is a 14px-gap column, and the gap belongs around the
   pair, not between them. The histogram carries the SAME 9px side margins as
   .filter-price-track-wrapper — that is what makes bar i sit over the stretch
   of track its thumb selects. Change one, change the other. */
.filter-price-chart {
    display: flex;
    flex-direction: column;
}

.filter-price-histogram {
    display: flex;
    align-items: flex-end;
    gap: 1px;
    height: 34px;
    margin: 0 9px;
}

.filter-price-bar {
    flex: 1 1 0;
    min-width: 0;
    background: #e0e0e0;          /* the unfilled track's grey — one component */
    border-radius: 1px 1px 0 0;
    transition: background-color 0.12s;
}

/* Selected range, mirroring .filter-price-fill. Follows the DRAFT handles, so
   the bars light up while the thumb is still moving. */
.filter-price-bar.in-range {
    background: var(--pink-highlight);
}

/* A price nobody offers draws nothing at all — a 1px stub would read as a
   handful of products rather than none. */
.filter-price-bar.is-empty {
    background: transparent;
}

@media (prefers-reduced-motion: reduce) {
    .filter-price-bar { transition: none; }
}

/* Wrapper provides vertical space so the 18px thumbs don't get clipped */
.filter-price-track-wrapper {
    position: relative;
    height: 6px;
    margin: 8px 9px;  /* 9px = half of thumb width, stops track from jumping at edges */
}

.filter-price-track {
    position: absolute;
    inset: 0;
    background: #e0e0e0;
    border-radius: 3px;
}

/* Pink fill between the two thumbs */
.filter-price-fill {
    position: absolute;
    top: 0;
    bottom: 0;
    background: var(--pink-highlight);
    border-radius: 3px;
}

/* Both range inputs overlap the same track area */
.filter-range-input {
    position: absolute;
    left: -9px;
    right: -9px;
    width: calc(100% + 18px);
    height: 6px;
    top: 0;
    margin: 0;
    padding: 0;
    background: transparent;
    -webkit-appearance: none;
    appearance: none;
    pointer-events: none;  /* track is not interactive — only thumb is */
}

/* Thumb styles — WebKit */
.filter-range-input::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: var(--pink-highlight);
    cursor: pointer;
    pointer-events: all;
    border: 2px solid #fff;
    box-shadow: var(--shadow-sm);
    transition: transform 0.1s;
}
.filter-range-input::-webkit-slider-thumb:hover {
    transform: scale(1.1);
}

/* Thumb styles — Firefox */
.filter-range-input::-moz-range-thumb {
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: var(--pink-highlight);
    cursor: pointer;
    pointer-events: all;
    border: 2px solid #fff;
    box-shadow: var(--shadow-sm);
}

/* ── Touch: keep the thumbs out of the screen-edge gesture strip ──────────
   On a phone the panel's 15px side padding + the track's 9px margin leave the
   left thumb's outer edge 15px from the viewport edge — inside iOS's back-swipe
   strip and Android's gesture-nav strip. A drag that starts there is claimed by
   the OS as a back swipe, so the shopper navigates away instead of filtering.

   The extra inset goes on .filter-price-chart, the shared parent, so the
   histogram and the track shift TOGETHER and bar i keeps standing over exactly
   the stretch of track its thumb selects — moving either 9px alone would
   decouple them.

   touch-action:none declares that this control owns the gesture, which is what
   stops Chrome turning a horizontal drag into a navigation. It is inherited by
   the thumb pseudo-element, which is the part that actually receives the touch
   (the input itself is pointer-events:none).

   The thumb also grows to 24px: 18px is a small thing to catch with a
   fingertip, and left/right compensation follows half the new width, or the
   browser's own thumb inset would stop the extremes lining up with the track. */
@media (max-width: 1099px), (hover: none) and (pointer: coarse) {
    .filter-price-chart { padding-inline: 20px; }

    .filter-range-input {
        left: -12px;
        right: -12px;
        width: calc(100% + 24px);
    }
    .filter-range-input::-webkit-slider-thumb { width: 24px; height: 24px; }
    .filter-range-input::-moz-range-thumb     { width: 24px; height: 24px; }
}

.filter-range-input { touch-action: none; }

/* Number inputs row */
.filter-price-inputs {
    display: flex;
    align-items: center;
    gap: 6px;
}

/* Commit row for the price draft. Only rendered while the handles differ from
   the applied filter, so it must not reserve space when absent — hence a plain
   flow row rather than a fixed-height footer. Left-aligned, so it sits under
   the min input and on the same edge as every other label and control in the
   panel rather than drifting out to the right. */
/* No margin-top: .filter-price-slider is a 14px-gap column, so any margin here
   stacks on top of that gap and sets this row 8px lower than every other. */
.filter-price-apply {
    display: flex;
    justify-content: flex-start;
}

.filter-price-input {
    width: 64px;
    padding: 5px 6px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-control);
    font-size: var(--fs-base);
    font-family: inherit;
    color: var(--text-color);
    text-align: center;
    outline: none;
    transition: border-color 0.15s;
    /* hide browser spinner arrows */
    -moz-appearance: textfield;
}
.filter-price-input::-webkit-inner-spin-button,
.filter-price-input::-webkit-outer-spin-button {
    -webkit-appearance: none;
    margin: 0;
}
.filter-price-input:focus {
    border-color: var(--pink-highlight);
}

.filter-price-sep {
    color: var(--text-light);
    flex-shrink: 0;
}
/* Currency symbol after the price range (/browse only — single-shop currency). */
.filter-price-currency {
    color: var(--text-light);
    flex-shrink: 0;
    font-size: var(--fs-base);
}

.filter-price-currency {
    color: var(--text-light);
    font-size: var(--fs-base);
    flex-shrink: 0;
}
/* ── end Filter Panel ──────────────────────────────────────────────── */

/* =========================================
   Shared Typography
   ========================================= */

/* Main page-level heading — h1/h2 used as primary section title */
.page-title {
    font-size: var(--fs-2xl);
    font-weight: 700;
    color: var(--text-color);
    line-height: 1.2;
}

/* Panel / card section heading — matches profile-section h2 pattern */
.section-title {
    font-size: var(--fs-lg);
    font-weight: 600;
    color: var(--text-color);
    padding-bottom: 10px;
    border-bottom: 1px solid var(--border-color);
    margin-bottom: 16px;
}

/* =========================================
   Shared Form Controls
   ========================================= */

.form-group {
    margin-bottom: 15px;
}

.form-label {
    display: block;
    margin-bottom: 5px;
    font-weight: 500;
    color: var(--text-color);
}

.form-control {
    width: 100%;
    padding: 10px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-control);
    font-family: inherit;
    font-size: var(--fs-lg);
    box-sizing: border-box;
    outline: none;
    transition: border-color 0.15s;
    /* Explicit, not the browser's: an <input> defaults to white but a
       <select> to light grey (#efefef in Chrome), so the gender select on the
       sign-up and profile forms stood out from the fields around it.
       :disabled below still wins (higher specificity). */
    background-color: var(--surface);
}
/* Single-line variants share the common control height as a floor; textarea
   .form-control (profile bio) keeps the block padding + its own min-height. */
input.form-control, select.form-control {
    min-height: var(--control-height);
    padding: 0.3em 10px;
}
select.form-control { cursor: pointer; }
/* A select's empty first option ("Your gender") is its placeholder: grey like
   an input's ::placeholder until a real answer is picked. The open list's
   options keep the normal text colour. */
select.form-control:has(> option[value=""]:checked) { color: var(--text-placeholder); }
select.form-control option { color: var(--text-color); }

.form-control:focus {
    border-color: var(--pink-highlight);
}

.form-control:disabled {
    background-color: var(--bg-light);
    color: var(--text-light);
    cursor: not-allowed;
}

/* Shared with the Light Sign Up modal, which opens over browse/search/home and
   cannot see pages/auth.css. Moved here from there unchanged. */
.social-btns {
    display: flex;
    /* Column, so each provider button keeps the full-width .btn-block it had
       when Google was the only one — a row would halve both and crush the
       labels. */
    flex-direction: column;
    gap: 10px;
    margin-bottom: 20px;
}

.divider {
    position: relative;
    text-align: center;
    margin: 20px 0;
}

.divider::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    height: 1px;
    background-color: var(--border-color);
    z-index: 0;
}

.divider span {
    background-color: var(--surface);
    padding: 0 10px;
    color: var(--text-light);
    font-size: var(--fs-sm);
    position: relative;
    z-index: 1;
    text-transform: uppercase;
}

.auth-link {
    color: var(--blue-highlight);
    text-decoration: none;
    font-weight: 500;
}

.auth-link.font-sm {
    font-size: var(--fs-sm);
    font-weight: 400;
    display: inline-flex;
    align-items: center;
}

/* Hover only where hover exists: on a touch screen :hover sticks after a tap,
   leaving the link in its hover colour until something else is tapped. */
@media (hover: hover) {
    .auth-link:hover,
    .auth-link.font-sm:hover {
        text-decoration: none;
        color: var(--pink-highlight);
    }
}

/* ── The shared sign-up / sign-in form ────────────────────────────────────
   One form, four surfaces: /signup, /login, the Light Sign Up modal and the
   outbound gate page (js/components/contents/AuthFormContent.js). These moved
   out of pages/auth.css unchanged when the modal stopped carrying its own
   reduced copy of the form — the modal opens over browse/search/home, which
   never load that sheet. Page LAYOUT (.auth-container, .auth-left, the pink
   pitch panel) stayed behind, because only the pages have it. */
.auth-form {
    text-align: left;
}

/* Email/name use the shared .form-control above. The only addition is the
   invalid state — the shared control has none, and the form sets aria-invalid
   on failed validation. */
.auth-form .form-control[aria-invalid="true"] {
    border-color: var(--error-color);
}

/* ── Password field ───────────────────────────────────────────────────────
   Reuses the shared "field with trailing actions" shell (.input-with-actions):
   a flex pill whose eye toggle is a standard .input-action-btn + <app-icon
   name="eye|eye-off">. The shell's defaults already match the sibling
   .form-control fields (token border, 6px radius, --control-height); these
   deltas only bump the text a step and apply the pink-focus / token-invalid
   colours. */
.auth-form .input-with-actions input {
    font-size: var(--fs-lg);
}

.auth-form .input-with-actions:focus-within {
    border-color: var(--pink-highlight);
}

.auth-form .input-with-actions:has(input[aria-invalid="true"]) {
    border-color: var(--error-color);
}

.form-actions-row {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
}

.checkbox-group {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 0;
}

.checkbox-group input {
    appearance: none;
    -webkit-appearance: none;
    background-color: var(--surface);
    margin: 0;
    cursor: pointer;
    width: 18px;
    height: 18px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-sm);
    display: grid;
    place-content: center;
    transition: all 0.2s;
}

.checkbox-group input::before {
    content: "";
    width: 10px;
    height: 10px;
    transform: scale(0);
    transition: 0.2s transform ease-in-out;
    box-shadow: inset 1em 1em white;
    transform-origin: center;
    clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}

.checkbox-group input:checked {
    background-color: var(--pink-highlight);
    border-color: var(--pink-highlight);
}

.checkbox-group input:checked::before {
    transform: scale(1);
}

.checkbox-label {
    font-size: var(--fs-sm);
    color: var(--text-light);
    font-weight: 400;
}

.legal-text {
    font-size: var(--fs-sm);
    color: var(--text-light);
    margin-bottom: 20px;
}

.auth-footer {
    margin-top: 20px;
    font-size: var(--fs-md);
    color: var(--text-light);
    /* Centred everywhere: the auth pages already centre .auth-left, but the
       sign-up MODAL does not, and the line sat flush left under a full-width
       button. */
    text-align: center;
}

/* The tick is decoration, and a vestibular trigger is not worth a 0.2s
   flourish. Travelled here with the checkbox it guards. */
@media (prefers-reduced-motion: reduce) {
    .checkbox-group input,
    .checkbox-group input::before {
        transition: none;
    }
}

/* =========================================
   Price History Modal
   ========================================= */

/* ── Shared modal product preview (Price History, Track Price, Save to List) ── */
.modal-product-preview { display: flex; gap: 12px; padding-bottom: 16px; border-bottom: 1px solid #f0f0f0; margin-bottom: 16px; align-items: center; }
.modal-product-img { width: 84px; height: 84px; object-fit: contain; border-radius: var(--radius-md); border: 1px solid #f0f0f0; flex-shrink: 0; }
.modal-product-info { flex: 1; min-width: 0; }
.modal-product-shop { font-size: var(--fs-xs); color: var(--text-light); margin: 0 0 3px; display: flex; align-items: center; gap: 4px; }
.modal-product-favicon { width: 14px; height: 14px; border-radius: 2px; }
.modal-product-title { font-size: var(--fs-base); font-weight: 600; color: var(--text-color); margin: 0 0 4px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
/* wrap: the row holds price + old price + the level pill, and on a narrow
   phone that is wider than the column. Without wrapping, flex SHRANK the price
   spans instead, so "155.43 €" broke at its own space and the currency symbol
   dropped to a second line beside the pill. Wrapping moves the whole pill down
   instead, which is the unit that should move. `align-items: center` is kept:
   with wrapping, each flex LINE centres its own items, so a pill that drops to
   its own row still sits level with that row. */
.modal-product-price-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
/* Keep each amount glued to its symbol. Safe as a bare nowrap ONLY because the
   parent wraps: a span that no longer fits takes its own line rather than
   overflowing, so this never converts long text into overflow with nowhere to
   go (see the box/type rules in CLAUDE.md). */
.modal-product-price, .modal-product-old-price { white-space: nowrap; }
/* Mirrors .product-price on the cards: body colour normally, brand pink only
   when the product is actually on sale. It used to be pink unconditionally,
   so every modal preview looked discounted. */
.modal-product-price { font-size: var(--fs-base); font-weight: 700; color: var(--text-color); }
.modal-product-price.discounted { color: var(--pink-highlight); }

/* ── Share modal ─────────────────────────────────────────────────────────
   Larger product image kept on the LEFT, in the normal preview row (≈189px —
   the earlier 252px reduced by 25%); the two share actions sit two-up on
   desktop, stacked on mobile. */
.share-modal .modal-product-img { width: 161px; height: 161px; max-width: 100%; }
/* Product preview text enlarged 25% (shop/title/price + favicon to match). */
.share-modal .modal-product-shop    { font-size: var(--fs-md); }
.share-modal .modal-product-favicon { width: 17px; height: 17px; }
.share-modal .modal-product-title   { font-size: var(--fs-lg); }
.share-modal .modal-product-price   { font-size: var(--fs-lg); }
.share-actions-row { display: flex; gap: 10px; }
.share-actions-row > * { flex: 1; }

/* ── Entity share modal (star / shop) ────────────────────────────────────
   Centred avatar + name above the same .share-actions-row the product and
   list sheets use. The avatar itself is .entity-header-avatar — the same
   round frame the profile header draws, reused rather than re-declared. */
.share-entity-preview {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;
    padding-bottom: 16px;
    border-bottom: 1px solid var(--border-color);
    margin-bottom: 20px;
}
.share-entity-preview .entity-header-avatar { margin-bottom: 6px; }
/* Not .entity-header-name: that one is fs-xl and ellipsised for a single
   header line — here the name is centred and free to wrap. */
.share-entity-name {
    font-size: var(--fs-lg);
    font-weight: 700;
    color: var(--text-color);
    text-align: center;
    margin: 0;
}

/* ── Share lists ───────────────────────────────────────────────────
   The lists this product sits in, each a full-width tappable row that
   opens the list share sheet (stacked). Separated from the actions
   above by the same hairline the product preview uses. Echoes the pink
   heart-circle identity from ShareListContent so the two read as one. */
.share-lists { margin-top: 20px; padding-top: 18px; border-top: 1px solid #f0f0f0; }
.share-col-list { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.share-col-row {
    display: flex; align-items: center; gap: 12px; width: 100%;
    padding: 10px 12px; text-align: left;
    background: var(--surface); border: 1px solid var(--border-color);
    border-radius: var(--radius-lg); cursor: pointer;
    transition: background 0.12s, border-color 0.12s;
}
@media (hover: hover) {
    .share-col-row:hover { background: var(--pink-highlight-pale); border-color: var(--pink-highlight); }
}
.share-col-avatar {
    flex: none; display: flex; align-items: center; justify-content: center;
    width: 36px; height: 36px; border-radius: 50%; color: var(--pink-highlight);
    background: var(--pink-highlight-pale); border: 1.5px solid var(--pink-highlight);
}
.share-col-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.share-col-name {
    font-size: var(--fs-md); font-weight: 600; color: var(--text-color);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.share-col-meta { font-size: var(--fs-xs); color: var(--text-light); }
.share-col-share { flex: none; color: var(--pink-highlight); }

/* ── Toolbar sort + mobile filter trigger (SortFilterBar.js) ──────────────
   /saved's pattern, shared by saved/search/browse: a boxed .form-select sort
   dropdown (visible at every width) + an icon-only .btn-neutral button that
   opens the mobile Filters bottom sheet holding the SAME FilterPanel as the
   desktop left column (the button is .mobile-only — desktop has the column).
   Rules were page CSS in mystuff.css until the bar went cross-page. */
/* Push the sort select + filter button to the right of the row. min-width:0 +
   a higher shrink priority let the select give up width first so the toolbar
   stays on ONE row at narrow widths (≈411px) instead of wrapping the filter
   icon to a second line. */
.browse-filter-row .form-select { margin-left: auto; min-width: 0; flex-shrink: 3; }
/* <sort-select> carries the push instead of its inner select / button. */
.browse-filter-row .sort-select { margin-left: auto; min-width: 0; flex-shrink: 3; }
.browse-filter-row .sort-select .form-select { margin-left: 0; }
/* Keep the whole toolbar on a single row: the fixed controls (view-toggle
   pills + the square filter trigger) never shrink or wrap; the flexible count
   label absorbs the remaining deficit and ellipsizes only in the extreme
   (.badge already sets white-space:nowrap). */
.browse-filter-row .toggle-pills,
.browse-filter-row .view-mode-toggle,
.browse-filter-row .btn-neutral { flex-shrink: 0; }
.browse-filter-row .badge-grey { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* Icon-only filter trigger — square at control height (neutral). Position
   anchor for the active-filter count badge pinned to its top-right corner
   (the badge is the same .badge.badge-pink the old text pill carried). */
.browse-filter-row .btn-neutral { position: relative; width: var(--control-height); padding: 0; }
.browse-filter-row .btn-neutral .badge { position: absolute; top: -7px; right: -7px; }
/* Active-filter chips under the toolbar — universal .chip (default compact
   size), this modifier only adds margins and breathing room before the
   first product card. */
.filter-chips-row { margin: 10px 0 16px; align-items: center; gap: 6px; }
/* Share List — one-row strip of the first products as thumbs only;
   the 6th box becomes "+N more" when the list holds more than 6. */
.share-thumbs-row { display: flex; justify-content: center; gap: 8px; margin-bottom: 16px; }
.share-thumb {
    /* Fluid up to 75px: grow/shrink from a 75px basis but never exceed it, so a
       full row of 6 fits any dialog width (narrow stacked sub-modal included)
       instead of overflowing. aspect-ratio keeps them square as they shrink. */
    flex: 1 1 75px; min-width: 0; max-width: 75px;
    aspect-ratio: 1 / 1; height: auto;
    border-radius: var(--radius-md); object-fit: cover;
    border: 1px solid #f0f0f0; background: var(--surface);
}
.share-thumb-more {
    display: flex; align-items: center; justify-content: center;
    background: var(--pink-highlight-pale); color: var(--pink-highlight);
    border-color: var(--pink-highlight-pale);
    font-size: var(--fs-3xs); font-weight: 700; text-align: center; line-height: 1.15;
}
@media (max-width: 600px) {
    .share-modal .modal-product-img { width: 102px; height: 102px; }
    .share-actions-row { flex-direction: column; }
    /* Stacked: drop the row's flex:1 (equal-WIDTH split) — on the vertical axis
       it collapses the buttons to content height. flex:none restores the
       standard .btn-primary control height; align-items:stretch keeps full width. */
    .share-actions-row > * { flex: none; }
    .share-thumbs-row { gap: 6px; }
}
.modal-product-old-price { font-size: var(--fs-base); color: var(--text-light); text-decoration: line-through; }
.modal-product-actions { flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.modal-product-list-label { display: block; font-size: var(--fs-xs); color: #999; margin-bottom: 3px; }

/* ── Variants modal ("View offers" multi-select) + bulk-mode summary strip ── */
.variants-select-all { display: flex; justify-content: flex-end; align-items: center; padding: 0 0 10px; font-size: var(--fs-base); color: var(--text-light); }
.variants-select-all label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.variants-list { display: flex; flex-direction: column; }
.variants-list .modal-product-preview { margin-bottom: 10px; padding-bottom: 10px; }
.variants-list .modal-product-preview:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
/* Active (currently-viewed) offer — a pale tinted band, full-bleed to the modal
   body padding (10px). Replaces the old "(viewing)" text label. The tint is
   doing the work a neutral could not: a grey light enough not to read as a
   disabled row was too close to white to find at a glance, so the cue is a
   hue instead of a darkness. Blue, not pink — pale pink is now this row's
   :hover tint (see .variants-row-pick below), and a band that matched it would
   read as "the pointer is here" rather than "this is the one you came from". */
.variants-list .modal-product-preview.variants-row-active {
    background: var(--blue-highlight-pale);
    border-radius: var(--radius-md);
    padding: 10px;
    margin-left: -10px;
    margin-right: -10px;
    border-bottom-color: transparent;
}
/* Pills between domain and title in the Compare-prices modal: the blue
   "current" (active row only) and the grey match type. Inline, side by side. */
.variants-list .modal-product-info > .badge { margin: 0 4px 4px 0; }
/* The grey pill and the lavender band sit at nearly the same lightness
   (#e9ecef vs #ecebfb), so on the active row the pill would dissolve into it —
   white is the only value that separates cleanly from both. */
.variants-list .modal-product-preview.variants-row-active .offer-match-pill { background: var(--surface); }
/* The picture / title / price block toggles the row's tick, so that block is a
   <label for> the checkbox — not a div with a click handler. The native pairing
   is why there is no JS here at all, why the box cannot flip twice, and why the
   Buy-now column needs no defence: it simply sits outside the label. It also
   gives the checkbox the accessible name it never had (shop, title, price).
   The label has to restate the row's flex geometry, because it now stands
   between .modal-product-preview and the two children that used to be its own.
   The tint pads sideways ONLY — margin-inline cancels the padding-inline, so
   the band gains breathing room without the row moving. Copying the active
   row's all-round `padding: 10px` would shift every row 10px down on hover.
   Behind (hover: hover): on a phone the tint sticks to the last row tapped,
   where it would read as a selection state it is not. */
.variants-row-pick { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; cursor: pointer; }
@media (hover: hover) {
    .variants-list .modal-product-preview.variants-row:hover {
        background: var(--pink-highlight-pale);
        border-radius: var(--radius-md);
        padding-inline: 10px;
        margin-inline: -10px;
    }
}
/* The one checkbox in the product pickers — the offers/variants list, the
   media picker's rows AND every "select all" header above them.

   It used to be a native box tinted with `accent-color`, which meant the tick
   was whatever glyph the OS draws and the select-all (which carried no class at
   all) rendered as a plain browser checkbox next to it: two different shapes and
   two different colours in the same modal.
   `appearance: none` + our own AppIcon `check` polyline as a background keeps the
   control identical in every browser and matches the rest of the icon set.
   The glyph below is the SAME path AppIcon ships for `check`
   ('<polyline points="20 6 9 17 4 12"/>', 24-viewBox, round caps/joins) — if that
   icon is ever redrawn, redraw it here too; a data URI cannot import it. */
.variants-check {
    -webkit-appearance: none;
    appearance: none;
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    cursor: pointer;
    margin: 0;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-sm);
    background: var(--surface);
    background-repeat: no-repeat;
    background-position: center;
    /* Glyph scaled with the box, not pinned — at 16px a 14px tick left almost no
       inset and read as a filled square at a glance. */
    background-size: 11px 11px;
    transition: background-color .12s ease, border-color .12s ease;
}
.variants-check:checked {
    background-color: var(--blue-highlight);
    border-color: var(--blue-highlight);
    /* stroke='white' rather than a hex — a # inside a data URI would have to be
       escaped as %23 and is a classic silent breakage when someone edits it. */
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
}
@media (hover: hover) {
    .variants-check:not(:checked):hover { border-color: var(--blue-highlight); }
}
.variants-check:focus-visible {
    outline: 2px solid var(--blue-highlight);
    outline-offset: 2px;
}

/* ── Media product picker (.pm-copy-*) ────────────────────────────────────
   The "Add products" search results, in the admin Media tab AND in the
   card-side Edit-media modal — which is why this is here and not in
   admin.css, which that modal cannot see.

   Selection is checkboxes and nothing else: the rows reuse .variants-check
   and the .variants-select-all header from the offers modal, so "tick what
   you want" looks the same wherever it appears. There is deliberately no
   per-row "+" button and no chip list under the results — the tick IS the
   selected state, and a second representation of it only invites the two to
   disagree. The whole row is a toggle target, not just the box. */
.pm-copy-results { max-height: 260px; overflow-y: auto; }
.pm-copy-row { cursor: pointer; }
.pm-copy-row:hover { background: var(--pink-highlight-pale); }
/* Ticked rows keep the pale tint so a long list reads at a glance, without
   competing with :hover. */
.pm-copy-row.pm-copy-row--on { background: var(--pink-highlight-pale); }
/* The result count sits left, the select-all checkbox right — the shared
   .variants-select-all is flex-end because it only ever had the one child. */
.pm-copy-select-all { justify-content: space-between; gap: 12px; }
.variants-bulk-summary { display: flex; align-items: center; gap: 12px; padding: 10px 0 14px; border-bottom: 1px solid #f0f0f0; margin-bottom: 16px; }
.variants-bulk-thumbs { display: flex; align-items: center; }
.variants-bulk-thumb { width: 66px; height: 66px; object-fit: contain; border-radius: var(--radius-control); border: 1px solid #f0f0f0; background: var(--surface); margin-left: -8px; }
.variants-bulk-thumb:first-child { margin-left: 0; }
/* px width, to line up with the 66px thumbs beside it; min-height rather than
   height because the "+N" is text and this box holds it. */
.variants-bulk-more { display: inline-flex; align-items: center; justify-content: center; width: 66px; min-height: 66px; border-radius: var(--radius-control); background: var(--surface-track); color: var(--text-light); font-size: var(--fs-base); font-weight: 600; margin-left: -8px; }
.variants-bulk-label { font-size: var(--fs-base); font-weight: 600; color: var(--text-color); }

/* Text utilities — stack composably. .text-muted is THE muted secondary
   text style (absorbed admin's .col-date / .col-sub and the notice date,
   2026-06-11); add .text-nowrap where wrapping would hurt (dates, slugs). */
.text-muted         { color: var(--text-light); font-size: var(--fs-base); }
.text-danger        { color: var(--error-color); }
/* Brand pink as text — the counter on a card action row, and anywhere a
   number or word should read as ours rather than as an error. Same token
   the action icons and .chip-accent already use. Colour ONLY: it must
   inherit the row's font-size, which is how it follows .product-actions
   down to --fs-xs on mobile. */
.text-accent        { color: var(--pink-highlight); }
/* The "@" in front of a handle — one pale sigil, every place a nickname is
   printed (star profile, star tile, the celebrities picker, a shared list's
   attribution line), so the name itself carries the weight. A real character in
   a wrapper rather than a ::before, so it survives copy-paste. Colour ONLY: it
   inherits whatever size and weight the line around it uses.
   --icon-faint (1.92:1) is deliberate and allowed here for the one reason that
   token permits: every .handle-at carries aria-hidden, so the sigil is a
   decorative glyph and not text — the handle itself stays --text-color and is
   what a screen reader reads. Same argument as --star-gold's; both are written
   down in docs/accessibility.md. Do NOT drop the aria-hidden and keep the
   colour: that combination is a 1.4.3 failure. */
.handle-at          { color: var(--icon-faint); }
.text-success       { color: var(--green-highlight-text); }
.text-strikethrough { text-decoration: line-through; }
.text-nowrap        { white-space: nowrap; }
/* Visually hidden, still in the a11y tree — the host for aria-live regions
   (4.1.3) and for naming a table/section that already carries a visible
   heading. NOT `display:none` / `visibility:hidden`: both remove the node
   from the tree, which is the whole thing we need to keep.
   Two deliberate departures from the house rules, both because this box is
   never painted: it takes a fixed 1px `height` rather than a `min-height`
   floor (a text-sized box is exactly what we are avoiding here), and it takes
   a bare `white-space: nowrap` — inside a 1px-wide box, wrapping otherwise
   puts each character on its own line, which some screen readers then spell
   out letter by letter. Neither can overflow, because clip-path erases the
   paint. Don't copy either exception onto a visible box. */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    border: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}
/* Helper text under a form control (was .chip-hint — nothing chip about it).
   Spacing only — compose: "form-hint text-muted" (+ .text-danger to warn). */
.form-hint          { margin: 8px 0 0; }

/* Search for similar: hint text + the "Remove all / Add all" link on one row.
   Baseline so the link sits on the hint's first line when the hint wraps. */
.similar-hint-row { display: flex; align-items: baseline; gap: 12px; margin-top: 8px; }
.similar-hint-row .form-hint { flex: 1; margin: 0; }

.price-stats-row { display: flex; gap: 8px; margin-bottom: 14px; }
/* Transparent border on every card, so outlining one (see .price-stat-current)
   changes only its colour — adding a real border to a single card would make it
   2px taller than its neighbours and jog the whole row. */
.price-stat-card { flex: 1; background: var(--bg-light); border: 1px solid transparent; border-radius: var(--radius-md); padding: 8px 10px; text-align: center; display: flex; flex-direction: column; gap: 2px; }
.price-stat-label { font-size: var(--fs-3xs); color: var(--text-light); text-transform: uppercase; letter-spacing: 0.03em; }
.price-stat-value { font-size: var(--fs-base); font-weight: 700; color: var(--text-color); }
.price-stat-date { font-size: var(--fs-3xs); color: var(--text-light); }
.price-stat-low .price-stat-value { color: var(--green-highlight-text); }
/* The current price IS the period low — outline the card in the same green the
   value already carries, rather than introducing another accent. */
.price-stat-low.price-stat-current { border-color: var(--green-highlight); }
/* Highest deliberately reads neutral, like Average — there is NO bare
   .price-stat-high rule. The pink accent is conditional and mirrors the green
   pair above: it only lands when the current price IS that high. */
.price-stat-high.price-stat-current { border-color: var(--pink-highlight); }
.price-stat-high.price-stat-current .price-stat-value { color: var(--pink-highlight); }
/* Trend badge (.price-trend, shared with ProductCard) follows its word, and may
   wrap under it — centered on its own line — when the word alone fills the card. */
.price-stat-trend { display: inline-flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 4px 6px; }
/* The level pill needs no rule of its own: it lives in .modal-product-price-row,
   which is already flex + align-items:center + 6px gap. */

.price-chart-footer { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
/* Period selector uses the shared .toggle-pills / .toggle-pill component
   (the canonical compact selector — was a bespoke .price-period-* set). */

.price-chart-wrap { position: relative; margin-bottom: 6px; }
.price-chart-svg { width: 100%; height: auto; display: block; }
.chart-label { font-size: var(--fs-3xs); fill: var(--text-light); font-family: inherit; }

.price-dot { transition: r 0.12s ease-out; }
/* Latest ("today") point — the only dot visible at rest, pulsed with the same
   keyframe StatsChart's discrete marker uses. */
.price-dot-latest {
    filter: drop-shadow(0 1px 2px rgba(182, 3, 120, 0.25));
    transform-box: fill-box;
    transform-origin: center;
    animation: chart-today-pulse 1.8s ease-in-out infinite;
}

/* left AND translateX are both the hovered day's % across the chart (set in
   PriceHistoryContent): 0% pins the box's left edge to the chart's, 100% its
   right edge, 50% centres it — so the box never leaves the chart and always
   covers the point it reports. A fixed -50% hung half of it off either end. */
.price-chart-tooltip { position: absolute; top: 0; background: var(--text-color); color: #fff; padding: 4px 8px; border-radius: var(--radius-control); font-size: var(--fs-xs); white-space: nowrap; pointer-events: none; z-index: 5; }
.price-chart-tooltip-orig { opacity: 0.7; }

.price-chart-legend { display: flex; gap: 16px; font-size: var(--fs-xs); color: var(--text-light); }
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-line { display: inline-block; width: 18px; height: 2.5px; border-radius: 2px; }
.legend-price { background: var(--pink-highlight); }
.legend-orig { background: var(--text-light); opacity: 0.5; background-image: repeating-linear-gradient(90deg, var(--text-light) 0 4px, transparent 4px 7px); background-color: transparent; height: 2px; }

.price-history-loading { text-align: center; padding: 20px 0; color: var(--text-light); font-size: var(--fs-base); }
.price-chart-empty { opacity: 0.6; }
.chart-empty-text { font-size: var(--fs-base); fill: var(--text-light); font-family: inherit; font-weight: 600; }
.chart-empty-sub { font-size: var(--fs-3xs); fill: var(--text-light); font-family: inherit; }

.modal-footer > a { text-align: center; text-decoration: none;
}

/* =========================================
   Instant Price Check Animations
   ========================================= */
.price-checking {
    animation: priceCheckPulse 1.2s ease-in-out infinite;
}
@keyframes priceCheckPulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.35; }
}

.price-updated {
    animation: priceFlash 1.5s ease-out forwards;
}
@keyframes priceFlash {
    0%   { color: var(--green-highlight-text); }
    70%  { color: var(--green-highlight-text); }
    100% { color: inherit; }
}

/* ═══════════════════════════════════════════════════════════════════════
   Product Group (GTIN carousel row in My Stuff grouped view)
   ═══════════════════════════════════════════════════════════════════════ */

.product-group {
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    background: var(--surface);
    padding: 12px 14px;
    margin-bottom: 15px;
}

.group-header {
    display: flex;
    flex-wrap: wrap;          /* lets the list eyebrow take its own row */
    align-items: center;
    gap: 10px;
    row-gap: 4px;             /* tighter eyebrow→title spacing when wrapped */
    margin-bottom: 10px;
}

.group-title {
    font-size: var(--fs-lg);
    font-weight: 500;
    color: var(--text-color);
    margin: 0;
    line-height: 1.3;
    flex: 1;
    min-width: 0;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}

.group-badge {
    flex-shrink: 0;
    background: var(--pill-grey-bg);
    color: var(--pill-grey-text);
    font-size: var(--fs-xs);
    font-weight: 600;
    padding: 3px 10px;
    border-radius: var(--radius-lg);
}

/* ── Compact card (slide inside group carousel) ────────────────────── */

.compact-card {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    padding: 10px 8px;
    /* Hairline + 8px corner, 2026-09-24: the owner wants the product tile to
       match the star/list tiles (.celeb-card) it shares the rails with, which
       kept theirs — the rail had read as two card languages side by side. */
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    background: var(--surface);
    height: 100%;
    box-sizing: border-box;
    /* Hairline AND --shadow-sm, like .celeb-card. The rails are white on
       WHITE (measured rgb(255,255,255) behind a rail card), so keep the
       shadow — softer than --shadow-sm and the edge disappears. Applies to
       BOTH compact surfaces (rail cards and GroupCard slides), which are meant
       to stay identical. box-sizing is border-box, so the 1px does not change
       the card's outer size or the carousel row alignment. */
    box-shadow: var(--shadow-sm);
}

.compact-card.product-inactive {
    filter: grayscale(40%) opacity(0.6);
}

.compact-image-container {
    margin-bottom: 8px;
}

.compact-img {
    width: 100%;
    max-width: 110px;
}

.compact-actions {
    display: flex;
    gap: 6px;
    margin-bottom: 8px;
    width: 100%;
    justify-content: center;
}

/* Sized from --control-height, NOT a hand-rolled padding box, so this row
   follows the touch tier like every other control: 38px for a pointer, 44px
   for a finger (core.css). It used to be `padding:6px` around a 14px glyph —
   a fixed 28px square on a phone, which is under the 44px a finger needs and
   left three tiny targets sitting above two full-width 44px CTAs.
   min-*, not width/height, so a platform that inflates the glyph still fits.
   Three is the most this row ever renders (Multimedia and Share are mutually
   exclusive) and 3x44 + 2x6 gap = 144px, inside the 169px of the narrowest
   card — the carousel tile on a 390px screen. A fourth would not fit, which
   is the measured reason behind the anti-crowding rule in CarouselCard. */
.compact-icon-btn {
    background: #f6f6f6;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-sm);
    cursor: pointer;
    padding: 6px;
    min-width: var(--control-height);
    min-height: var(--control-height);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--pink-highlight);     /* icon-only button — the svg IS the content */
    transition: all 0.15s;
}

@media (hover: hover) {
    .compact-icon-btn:hover {
        background: var(--pink-highlight-pale);
        color: var(--pink-highlight);
        border-color: var(--pink-highlight);
    }
}

.compact-icon-btn.tracking {
    background: var(--pill-green-bg);
    color: var(--pill-green-text);
    border-color: var(--pill-green-text);
}

/* Bottom-pinned action pair — pushes track + buy buttons to the end of the
   flex-column compact card. Buttons inherit their full ProductCard styling. */
.compact-actions-bottom {
    margin-top: auto;
    display: flex;
    flex-direction: column;
    gap: 6px;
    width: 100%;
}

/* Deliberate, SCOPED exception to the standard button set: in the compact
   cards (GroupCard + CarouselCard — the only two users of this container)
   Track price sits directly under the filled blue Buy now, and a second
   outlined box there reads as two competing CTAs. Border only — the pink
   label, the bell, the height and the pale-pink hover/active fill all stay,
   so the control still announces itself on pointer devices.
   `border-color`, NOT `border: none`: the base rule gives every button a
   1px box, and removing it would make this button 2px shorter than Buy now
   and break the pair's alignment.
   Do NOT promote this to a global `.btn` variant — everywhere else keeps
   the standard outlined .btn. */
.compact-actions-bottom .btn {
    border-color: transparent;
}

/* Domain line — given its own air so it reads as a separate line rather than
   clinging to the icon row above and the button pair below. .compact-actions
   already contributes 8px, so margin-top:4px lands 12px above vs 10px below —
   even to the eye. Font-size drops its ad-hoc 0.72rem for the standard
   --fs-xs (0.75rem), i.e. the same size the full ProductCard's domain uses;
   going a step further would make the SMALL card's domain the larger of the
   two. */
.compact-card .product-shop-link {
    margin-top: 4px;
    margin-bottom: 10px;
    font-size: var(--fs-xs);
}

.compact-card .product-brand {
    font-size: var(--fs-xs);
    margin-bottom: 4px;
}

.compact-card .product-price-block {
    min-height: auto;
    margin-bottom: 8px;
    width: 100%;
}

/* Compact cards stack tightly; the availability line had no vertical margin and
   clamped flush against the title above and the price below (measured 0px). Give
   it symmetric breathing room, and a little space under the title, so the
   title / availability / price read as three distinct lines, not one clump. */
.compact-card .product-title { margin-bottom: 5px; }
/* Availability pill hidden on the compact card for now. Deliberately hidden,
   NOT removed: the markup, the freshness classes and the tooltip wiring in
   CarouselCard.js all stay live, so restoring it is deleting this one line. */
.compact-card .product-availability { display: none; }
.compact-card .product-availability { margin: 2px 0 7px; }

.compact-card .product-price {
    font-size: var(--fs-md);
}

.compact-card .product-old-price {
    font-size: var(--fs-xs);
}

/* Splide arrows — 20% smaller, centered on the image.
   No hover/focus/active visual change at all; no transitions. */
.product-group .splide__arrow,
.product-group .splide__arrow:hover,
.product-group .splide__arrow:hover:not(:disabled),
.product-group .splide__arrow:focus,
.product-group .splide__arrow:focus-visible,
.product-group .splide__arrow:active {
    background: var(--surface) !important;
    border: 1px solid var(--border-color) !important;
    opacity: 0.95 !important;
    outline: none !important;
    box-shadow: none !important;
    transition: none !important;
    top: 25%;
    transform: translateY(-50%) scale(0.8);
}

.product-group .splide__arrow svg,
.product-group .splide__arrow:hover svg,
.product-group .splide__arrow:focus svg,
.product-group .splide__arrow:active svg {
    fill: #000 !important;
    transition: none !important;
}

.product-group .splide__arrow:disabled {
    opacity: 0.4 !important;
    cursor: default;
}

/* Above the phone tier: hide arrows for 2-item groups (both cards already fit
   side-by-side). Below it the cards stack one-up, so the arrows are the only
   way to reach the second card and must stay. */
@media (min-width: 601px) {
    .product-group.group-count-2 .splide__arrows {
        display: none;
    }
}

/* ── Admin hint on product image (admin-only) ────────────────────────── */
/* 1.667em x 14.4px (--fs-md) + flex centring: the same 24px circle, and the "?"
   no longer sits on a px line-height that cannot follow it.
   Placed in the top-left column, stacked UNDER the 24px bookmark
   (.icon-favorite, top:0 left:0) — it used to sit bottom-right, where the
   "new" pill (.product-card-new) now lives and covered it. The left column is
   the only edge with vertical room: top-right is the discount circle, bottom-
   left the Inactive pill + CDN cue, bottom-right the "new" pill.
   The gap under the bookmark is a deliberate 14px, not a tight stack — the
   two are unrelated controls (buyer action vs. admin debug) and should not
   read as one button group. */
.admin-hint-btn {
    position: absolute;
    top: 38px;
    left: 1px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.667em;
    height: 1.667em;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.9);
    border: 1px solid var(--border-color);
    color: #666;
    font-size: var(--fs-md);
    font-weight: 700;
    line-height: normal;
    cursor: pointer;
    z-index: 30;
    user-select: none;
}

.admin-hint-btn:hover {
    background: var(--surface);
    color: var(--pink-highlight);
    border-color: var(--pink-highlight);
}

/* Opens DOWNWARD from the button's top-left anchor (38px top + the circle,
   +4px gap). Anchored left so it grows into the right-column area of
   the card — the same escape the mobile rule below has always used, since the
   image container itself is far narrower than the popup. */
.admin-hint-popup {
    position: absolute;
    /* Button top + its 1.667em circle + 4px. em, not a pinned 24px: the popup
       and the button share --fs-md, and on a phone laid out at 480px
       (viewport.js) the circle renders 32px — a pinned 66px drew the popup
       over the bottom of its own "?". */
    top: calc(38px + 1.667em + 4px);
    left: 2px;
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-control);
    padding: 10px 13px;
    font-size: var(--fs-md);
    line-height: 1.5;
    z-index: 40;
    box-shadow: var(--shadow-md);
    max-width: 338px;
    min-width: 234px;
    word-break: break-all;
    user-select: text;
    -webkit-user-select: text;
    text-align: left;
}

.admin-hint-popup .hint-label {
    color: var(--text-light);
    margin-right: 4px;
}

.admin-hint-popup .hint-val {
    font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
    color: var(--pink-highlight);
}
/* id/gtin deep-link into admin Products — underline on hover signals clickable. */
.admin-hint-popup a.hint-link {
    text-decoration: none;
    cursor: pointer;
}
.admin-hint-popup a.hint-link:hover {
    text-decoration: underline;
}

/* One-click copy next to the id/gtin values — flashes a green check. */
.admin-hint-popup .hint-copy {
    display: inline-flex;
    align-items: center;
    vertical-align: -2px;
    margin-left: 6px;
    cursor: pointer;
    color: var(--text-light);
}
.admin-hint-popup .hint-copy:hover {
    color: var(--pink-highlight);
}
.admin-hint-popup .hint-copy.copied {
    color: var(--green-highlight-text);
}

/* Mobile: the image container sits in the left 50% of .product-card, so the
   popup's natural width would overflow the card's right edge and get clipped
   by .product-card { overflow: hidden }. Left anchoring is already the base
   rule now; this only narrows it to what the half-card can hold. */
@media (max-width: 1200px) {
    .admin-hint-popup {
        min-width: 0;
        max-width: 240px;
    }
}

/* GroupCard's compact offer thumbs carry a 20px bookmark (not 24px), so the
   stack under it closes up by 4px to keep the same gap. */
.compact-image-container .admin-hint-btn {
    top: 34px;
}
.compact-image-container .admin-hint-popup {
    top: calc(34px + 1.667em + 4px);
}

/* Admin-only visual cue: mark images that fell back from cdn.eshops.bg to a
 * direct shop origin URL (hotlink). The .img-hotlink class is set on the <img>
 * by window.onProductImageError (api.js) on @error; body.is-admin is from
 * auth.js. The cue is the cdn-off icon next to the bookmark in the card's
 * top-left corner (was: a bare highlight dot at the bottom edge). */
.cdn-off-cue {
    color: var(--icon-faint);
    pointer-events: none;
    display: inline-flex;
    /* white halo so the icon reads on busy images, like the old dot's ring */
    filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.95));
}
.product-image-container .cdn-off-cue {
    position: absolute;
    bottom: 4px;
    left: 4px;   /* bottom-left corner, clear of the top-left bookmark */
    z-index: 12; /* above corner badges (z 10) so it stays visible */
    display: none;
}
body.is-admin .product-image-container:has(> .img-hotlink) .cdn-off-cue { display: inline-flex; }

/* ═══════════════════════════════════════════════════════════════════════════
   Statistics dashboard widgets (Phase 2B)
   ───────────────────────────────────────────────────────────────────────────
   Generic — used by the admin Shop Dashboard today, will be reused by the
   Celebrity Dashboard and the future self-serve portals.
   ═══════════════════════════════════════════════════════════════════════════ */

/* General-box / KPI quick-filter card. Generic base lives here (not admin.css)
   so user-facing stats pages — which load components.css but NOT admin.css —
   can render it. Admin-only tints (.stat-danger, .server-zone-*) stay in
   admin.css, retargeted to .stat-card. */
.stat-card {
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    padding: 14px 20px;
    min-width: 120px;
    text-align: center;
}
.stat-card.clickable { cursor: pointer; transition: border-color .15s, background .15s; }
@media (hover: hover) { .stat-card.clickable:hover { border-color: var(--pink-highlight); } }
/* Selected card (the active quick-filter) */
.stat-card.active { border-color: var(--pink-highlight); background: var(--pink-highlight-pale); }

.stat-card > strong {
    display: block;
    font-size: var(--fs-2xl);
    font-weight: 700;
    color: var(--pink-highlight);
    line-height: 1;
}

/* Direct children only — the card can host nested components (<stat-items>)
   whose own spans/strongs must not inherit the headline/label styling. */
.stat-card > span {
    display: block;
    font-size: var(--fs-xs);
    color: #999;
    margin-top: 4px;
    text-transform: uppercase;
    letter-spacing: 0.03em;
}

.stats-dashboard {
    padding: 4px 0 16px;
}

.stats-dashboard-header {
    display: flex;
    justify-content: flex-end;
    margin-bottom: 18px;
}

.stats-dashboard-loading,
.stats-dashboard-error {
    padding: 36px 12px;
    text-align: center;
    color: var(--text-light);
    font-size: var(--fs-md);
}

.stats-dashboard-error {
    color: var(--error-color);
}

/* ─── Period selector ─── */
.toggle-pills {
    display: inline-flex;
    gap: 4px;
    background: var(--surface-track);
    border-radius: var(--radius-md);
    padding: 3px;
}

.toggle-pill {
    /* Flex-centre the label so the fixed-height variants (.control-height /
       .full) sit it dead-centre regardless of the inherited 1.55 line-height —
       same pattern as .chip: flex owns the vertical rhythm, line-height:normal
       gives the strut symmetric leading, svg:block drops the inline baseline gap. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    line-height: normal;
    background: transparent;
    border: none;
    padding: 6px 14px;
    border-radius: var(--radius-control);
    font-family: inherit;
    font-size: var(--fs-base);
    font-weight: 500;
    color: var(--text-light);
    cursor: pointer;
    transition: background 0.15s, color 0.15s;
}
.toggle-pill svg { display: block; }

@media (hover: hover) { .toggle-pill:hover  { color: var(--text-color); } }
/* Selected = a quiet white pill ringed by a 1px neutral border, NOT the brand
   colour — a toggle picks a mode, it isn't a primary action, so pink stays
   reserved for real actions. The ring is an inset box-shadow (not a real border)
   so the pill keeps the exact same dimensions as its neighbours — no growth, no
   shift, and no layout impact. */
.toggle-pill.active {
    background: var(--surface);
    color: var(--text-color);
    font-weight: 600;
    box-shadow: inset 0 0 0 1px var(--border-color);
}

/* Control-height variant — sizes the track to --control-height (pill height +
   the 3px track padding top & bottom) so the toggle aligns with sibling form
   controls — selects, buttons — in a toolbar row. Width stays content-based.
   A floor: the base .toggle-pill already sizes off its label, so pinning a
   height here would have been the one thing that took that away. */
.toggle-pills.control-height .toggle-pill { min-height: calc(var(--control-height) - 6px); }

/* Full-width variant — stretches the track and splits it into equal segments
   (replaces the old full-width .seg-toggle; use for in-form Private/Public,
   expiry, etc.). Full-width implies control height too. */
.toggle-pills.full { display: flex; width: 100%; }
.toggle-pills.full .toggle-pill { flex: 1; min-height: calc(var(--control-height) - 6px); }

/* ─── Secondary nav tabs — pill sub-navigation (admin sub-tabs & co.).
       The pane that follows carries .secondary-nav-content-wrapper, which
       owns the tabs↔content gap as margin-top. ─── */
.secondary-nav-tabs {
    display: flex;
    gap: 4px;
    flex-wrap: wrap;
}

.secondary-nav-tab {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    background: var(--surface-track);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-control);
    padding: 6px 14px;
    font-family: inherit;
    font-size: var(--fs-base);
    font-weight: 500;
    color: #666;
    cursor: pointer;
    transition: background 0.15s, color 0.15s;
    /* Pair with horizontal-scroll variants (e.g. admin's
       .modal-sub-tabs): each pill stays on one line and doesn't
       shrink, so the row scrolls horizontally instead of wrapping. */
    white-space: nowrap;
    flex-shrink: 0;
}

@media (hover: hover) { .secondary-nav-tab:hover  { background: #ececec; color: var(--text-color); } }
.secondary-nav-tab.active { background: var(--pill-pink-bg); color: var(--pill-pink-text); border-color: #f0b8d8; }

/* Status dot inside a tab (e.g. crawl methods: gray = off, green = on);
   the tab's flex layout centers it with the label, no vertical-align hacks. */
.secondary-nav-dot { width: 7px; height: 7px; border-radius: 50%; background: #ccc; flex-shrink: 0; }
.secondary-nav-dot.on { background: var(--green-highlight); }

.secondary-nav-content-wrapper {
    margin-top: 20px;
}

/* On phones the tab rows (e.g. admin Crawlers' 9 sub-tabs) wrap to 3 lines
   and eat vertical space — swipe them horizontally instead. */
@media (max-width: 768px) {
    .secondary-nav-tabs {
        flex-wrap: nowrap;
        overflow-x: auto;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none;
    }
    .secondary-nav-tabs::-webkit-scrollbar { display: none; }
}

/* ─── Section blocks ─── */
.stats-section {
    margin-bottom: 28px;
    padding-bottom: 20px;
    border-bottom: 1px solid var(--border-color);
}

.stats-section:last-child {
    border-bottom: none;
}

.stats-section-title {
    margin: 0 0 14px 0;
    font-size: var(--fs-lg);
    font-weight: 600;
    color: var(--text-color);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.stats-section-placeholder {
    opacity: 0.6;
}

.stats-section-empty {
    margin: 0;
    padding: 16px;
    background: var(--bg-light);
    border-radius: var(--border-radius);
    color: var(--text-light);
    font-size: var(--fs-md);
    font-style: italic;
}

/* ─── KPI grid ─── */
.stats-kpi-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
    gap: 12px;
    margin-bottom: 18px;
}

.stats-kpi-card {
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius);
    padding: 14px 16px;
    text-align: right;   /* numbers read better right-aligned */
}

.stats-kpi-label {
    margin: 0 0 8px 0;
    font-size: var(--fs-base);
    font-weight: 500;
    color: var(--pink-highlight);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.stats-kpi-value {
    margin: 0;
    font-size: var(--fs-2xl);
    font-weight: 600;
    color: var(--text-color);
    line-height: 1.1;
}

/* Trailing word after the headline number ("7 clicks", "0 views") */
.stats-kpi-unit {
    margin-left: 5px;
    font-size: var(--fs-sm);
    font-weight: 500;
    color: var(--text-light);
}

.stats-kpi-sub {
    margin: 8px 0 0 0;
    font-size: var(--fs-base);
    color: var(--text-light);
}

.stats-kpi-sub-label  { margin-right: 4px; }
.stats-kpi-sub-value  { font-weight: 600; color: var(--text-color); }

/* ─── Chart ─── */
.stats-chart {
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius);
    padding: 14px 16px;
}

.stats-chart-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 8px;
    gap: 12px;
}

.stats-chart-title {
    font-size: var(--fs-base);
    font-weight: 600;
    color: var(--text-color);
}

.stats-chart-canvas {
    min-height: 280px;
}

/* StatsChart dots are size 0 (hover inflates the pointed-at one); the only
   always-visible marker is the latest ("today") point — the discrete size-4
   marker from StatsChart.js — which gets a pulse. Scoped to the stats chart
   so other charts are unaffected. The price-history chart uses the same
   keyframe on .price-dot-latest, hence the chart-* (not stats-*) name. */
.stats-chart-canvas .apexcharts-marker[default-marker-size="4"] {
    filter: drop-shadow(0 1px 2px rgba(182, 3, 120, 0.25));
    transform-box: fill-box;
    transform-origin: center;
    animation: chart-today-pulse 1.8s ease-in-out infinite;
}

@keyframes chart-today-pulse {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.25); }
}

/* The dashboard's general box — a .stat-card holding one <stat-items> row
   (the old .stat-strip variant is gone; <stat-items> replaced it). Doubled
   class so the padding beats .stat-card's 14px 20px base by specificity. */
.stat-card.stats-general-box {
    padding: 14px;
    margin-bottom: 18px;
}

/* A heads-up banner inside a dashboard joins the 18px block rhythm every other
   dashboard block keeps (.stats-general-box, .stats-perf-tabs,
   .stats-shop-controls). .notice's own 4px is tuned for admin's stacked alert
   lists, where a tight run is the point — hence the override here rather than
   a change to .notice itself. */
.stats-dashboard > .notice,
.stats-dashboard > .explainer-panel {
    margin-bottom: 18px;
}

/* ─── <stat-items> — flex row of icon + number + label trios (shop header,
       dashboard general box) ─── */
.stat-items {
    display: flex;
    justify-content: space-around;
}

.stat-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-width: 40px;
}

.stat-item-val {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 2px;
    font-size: var(--fs-lg);
    font-weight: 700;
    color: var(--text-color);
    line-height: 1.1;
}

/* Metric icon takes the label gray; the number stays dark */
.stat-item-val svg {
    color: var(--text-light);
}

.stat-item-lbl {
    font-size: var(--fs-3xs);
    color: var(--text-light);
    text-transform: uppercase;
    letter-spacing: 0.03em;
    white-space: nowrap;
}

/* ── Entity header card ──────────────────────────────────────────────
   Stacked card of hairline-divided rows — the chrome behind /browse's shop
   header and /star's profile header (see the <entity-header> component). It is
   entity-agnostic: rows are .entity-header-row children, an optional leading
   .entity-header-avatar sits beside the name, and a trailing .stat-items row
   auto-spaces via the override below. Consumers add their own entity-specific
   content classes (delivery, bio, …) alongside these. */
.entity-header {
    display: flex;
    flex-direction: column;
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    margin-bottom: 14px;
}
.entity-header-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 14px 14px;
}
.entity-header-row + .entity-header-row {
    border-top: none;
    background-image: linear-gradient(var(--border-color), var(--border-color));
    background-size: calc(100% - 28px) 1px;
    background-position: center top;
    background-repeat: no-repeat;
}
/* Leading group: optional avatar beside the name/subtitle stack. Grows rather
   than sitting at content width: the subtitle line parks a trailing counter
   (.entity-header-sub-count) at its right end, which needs the column to reach the
   card's edge — or, where the row still carries the CTA pair, up to it. */
.entity-header-lead {
    display: flex;
    align-items: center;
    gap: 12px;
    min-width: 0;
    flex: 1;
}
/* An image frame, not a label — px is right, nothing inside it is type.
   position: relative so the #avatar-badge slot (the verified ✓ dot) can pin to
   its corner exactly as it does on <celebrity-card>. */
.entity-header-avatar {
    position: relative;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background-color: var(--bg-light);
    background-size: cover;
    background-position: center;
    border: 2px solid var(--pink-highlight);
    flex-shrink: 0;
}
/* Owner/manager button row under the identity row (#actions slot). Right-
   aligned so it hangs off the same edge as the Follow/Share pair on row 1 —
   the base row is a space-between flex, so the alignment has to be restated.
   It used to wrap, which dropped the third button alone onto line 2 on a
   phone; <overflow-actions> now measures the row instead and hangs whatever
   does not fit behind a "…", so there is nothing left to wrap. */
.entity-header-actions {
    justify-content: flex-end;
    gap: 8px;
}
/* <overflow-actions> — the measured action row. One line, always: the
   component hides the buttons that do not fit rather than letting them wrap,
   and overflow:hidden is the belt to that braces, so a button mid-measurement
   can never paint outside the card. min-width:0 lets the row shrink inside a
   flex parent, which is what makes clientWidth the real available space. */
.overflow-actions {
    display: flex;
    align-items: center;
    flex-wrap: nowrap;
    overflow: hidden;
    min-width: 0;
    gap: 8px;
}
/* The trailing "…". Never grows and never shrinks: it is an icon frame
   (.btn-square), not a label, and the measurement charges the row for exactly
   the width it reads here. The ≤600px grow rule below skips it by matching
   items only, so this states the intent rather than fighting for it. */
.overflow-actions-more {
    flex: 0 0 auto;
}
/* Row 1's trailing pair (#action slot): Follow + the icon-only Share. Never
   shrinks — the name beside it is the ellipsising half. */
.entity-header-cta {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-shrink: 0;
}
/* Share carries its label in the markup at every width, but beside the name it
   is the icon-only square the pair needs to fit there — the label is for the
   mobile block below, where the pair takes a line of its own and Share grows
   back into a labelled button. The button's own aria-label names it in both
   states, so nothing is lost while the text is hidden. Not scoped to the share
   button by a class of its own: .btn-square IS the "no label here" state. */
.entity-header-cta .btn-square span {
    display: none;
}
.entity-header-info {
    display: flex;
    flex-direction: column;
    gap: 6px;                         /* air between the name and the subtitle/rating line */
    min-width: 0;
    flex: 1;                          /* grows with .entity-header-lead, so the subtitle
                                         line reaches the edge its trailing counter parks at */
}
.entity-header-name {
    font-size: var(--fs-xl);
    font-weight: 600;
    color: var(--text-color);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* nameHref: the name as a link — same register as .product-title a. */
.entity-header-name-link { color: inherit; text-decoration: none; }
.entity-header-name-link:hover { color: var(--pink-highlight); }
.entity-header-sub {
    display: flex;
    align-items: center;
    gap: 4px;
    font-size: var(--fs-lg);
    color: var(--text-light);
}
/* Trailing counter on the subtitle line — the shop's fans today, and named
   for the slot rather than for that number: any host with one figure worth
   reading beside the identity line (a star's followers, say) hangs it here
   too. The value takes the line's own size darkened to --text-color, and the
   word beside it borrows <stat-items>'s own .stat-item-lbl — same size, same
   gray, same uppercase — so the pair reads identically to the counter it
   swaps places with at 600px. The icon carries no stroke prop: currentColor
   on this line is already --text-light, the gray .stat-item-val svg uses. */
.entity-header-sub-count {
    display: flex;
    align-items: center;
    gap: 4px;
    margin-left: auto;
    flex-shrink: 0;
}
.entity-header-sub-count b {
    color: var(--text-color);
}
/* One figure, two homes, switched at THIS component's own boundary — the 600px
   below which .entity-header-cta leaves row 1 for a line of its own. While the
   Follow/Share pair still sits in row 1's corner, a counter wedged between the
   rating and those buttons reads as stranded, so it belongs in the stats row;
   once the pair has vacated the line there is room for it, and the stats row is
   the tighter of the two on a phone. Not .desktop-only/.mobile-only (core.css):
   those switch at 1100px, which would leave the counter stranded for the whole
   601-1099px band. The host renders both copies and marks each with one of
   these (the stats one via <stat-items>'s per-item `cls`). Ordered after
   .entity-header-sub-count / .stat-item so plain source order beats their
   display, no !important. */
.entity-header-narrow-only {
    display: none;
}
.entity-header-favicon {
    width: 14px;
    height: 14px;
    border-radius: 2px;
    flex-shrink: 0;
}
/* nameIcon variant (favicon inside the 1.4rem name line) — a bit larger and
   baseline-tucked; the subtitle favicon above keeps its 14px flex sizing. */
.entity-header-name .entity-header-favicon {
    width: 18px;
    height: 18px;
    margin-right: 6px;
    vertical-align: -2px;
}
/* Mobile: smaller shop name in the branding blocks (/browse header + the
   shop-summary modal). Scoped so celebrity/stars entity headers keep 1.4rem. */
@media (max-width: 600px) {
    .browse-controls .entity-header-name,
    .shop-summary-body .entity-header-name { font-size: var(--fs-xl); }

    /* The buttons an <overflow-actions> row kept fill the line rather than
       ending short of the "…" — two grown buttons and the ellipsis read as one
       set, two natural-width ones and a gap read as an accident. Only here: on
       desktop the whole row fits with room to spare, and growing them there
       would stretch the set across the header. Matched on the item attribute,
       not on *, so the "…" keeps the square width .btn-square gives it. */
    .overflow-actions > [data-overflow-item] {
        flex: 1 1 auto;
    }

    .entity-header-wide-only {
        display: none;
    }
    .entity-header-narrow-only {
        display: flex;
    }

    /* Follow + Share leave row 1's trailing corner and take a line of their
       own. Beside the name they cost the name column ~150px, which is most of
       a 390px phone — the pair was only fitting there because the avatar and
       the row gaps were shrunk to pay for it. flex-basis:100% is what forces
       the wrap (the row's own 12px gap becomes the line gap); the two then
       split the line evenly, so Share alone — the owner's own star profile,
       which has no Follow — fills it. */
    .entity-header-row:has(> .entity-header-cta) {
        flex-wrap: wrap;
    }
    /* Its own line, so it carries the same hairline every other row boundary in
       this card gets. The declarations mirror .entity-header-row +
       .entity-header-row above; background-size is plain 100% rather than
       calc(100% - 28px) because the pair already sits inside the row's 14px
       padding, so its box IS the inset the row rule has to compute. The row's
       12px gap sits above the line and the padding puts the same below it. */
    .entity-header-cta {
        flex: 1 0 100%;
        padding-top: 12px;
        background-image: linear-gradient(var(--border-color), var(--border-color));
        background-size: 100% 1px;
        background-position: center top;
        background-repeat: no-repeat;
    }
    .entity-header-cta > * {
        flex: 1 1 0;
    }
    /* Share has its label back here, so it is a label box again, not an icon
       frame: the base button's box (min-height floor + em padding) replaces
       .btn-square's fixed square. */
    .entity-header-cta .btn-square {
        width: auto;
        padding: 0.3em 24px;
    }
    .entity-header-cta .btn-square span {
        display: inline;
    }
}
/* Optional "back" affordance placed above the card (e.g. "‹ Back to all shops"). */
.entity-header-back {
    display: flex;                    /* was block for the old ❮ glyph; now an <svg> + text pair */
    align-items: center;
    gap: 4px;
    width: fit-content;               /* keep the click target on the text, not the full row */
    font-size: var(--fs-lg);
    color: var(--pink-highlight);
    cursor: pointer;
    line-height: 2;
    flex-shrink: 0;
    margin-bottom: 10px;              /* air between the back link and the content below */
}
/* Used as a <span> on /browse and as an <a> on the star page. As a link the
   global a:hover in core.css (0,1,1) would underline it and repaint it blue;
   a.entity-header-back:hover is (0,1,1) too but wins on source order — it is
   defined later than core.css in the cascade. */
.entity-header-back:hover { opacity: 0.7; }
a.entity-header-back,
a.entity-header-back:hover { color: var(--pink-highlight); text-decoration: none; }
/* .stat-items loads before this and sets space-around; a header stats row
   restates it so .entity-header-row's space-between doesn't win. */
.entity-header-row.stat-items {
    justify-content: space-around;
}

/* ── Thumb strip ────────────────────────────────────────────────────
   Instagram-style row of thumbnail + two-line-caption cells, scrolled
   horizontally. The names are deliberately entity-neutral — these rules were
   the .star-highlight* block until a second host arrived, and the old names
   were already documented as historical. Two hosts today:
     · <star-highlights> — a star's Lists / a shop's Collections, inside an
       .entity-header card (celebrity.html, browse.html, ShopSummaryContent);
     · the mobile menu's "Last seen" products (TheHeader.js).
   Lives here, not in css/pages/celebrity.css, because /browse loads only
   core + components + browse.css.

   Horizontal overflow gives touch swipe + trackpad scroll for free (same idiom
   as .admin-tabs / .modal-tabs); StarHighlights.js adds mouse click-and-drag,
   which overflow alone does not provide — so the grab cursor is scoped to that
   host instead of being promised by the base, where nothing would answer it. */
/* Carries .entity-header-row for the 14px gutter every other header row uses —
   without it the first circle sat 14px left of the bio and stats text — and to
   keep the row-to-row divider chain (.entity-header-row + .entity-header-row)
   unbroken for the stats row underneath. The row default is a space-between
   flex; this one is a scroller, so display is overridden back to block. */
.star-highlights.entity-header-row {
    display: block;
    width: 100%;
    padding-top: 10px;
    padding-bottom: 10px;
}

.thumb-strip {
    display: flex;
    gap: 14px;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    padding: 2px 0 4px;
}
.thumb-strip::-webkit-scrollbar { display: none; }
/* Grab is a promise that click-and-drag works, and only StarHighlights.js
   wires the pointer handlers that keep it. A strip with no drag host scrolls
   by swipe/trackpad and says so with the default cursor. */
.star-highlights .thumb-strip { cursor: grab; }
.thumb-strip.is-dragging { cursor: grabbing; }
/* While dragging, kill text/image selection so the pull doesn't highlight
   the captions. */
.thumb-strip.is-dragging .thumb-strip-item { user-select: none; }

.thumb-strip-item {
    flex: 0 0 auto;
    /* Wider than the 60px thumb it centres, so the caption has room before
       its two-line ellipsis; the thumb size is unchanged. */
    width: 84px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 5px;
    text-decoration: none;
    color: var(--text-color);
}

/* The frame is on a wrapper, not the image, so a count badge can sit on the
   edge without being clipped by the image's own border-radius. Equal-sided and
   styling no type — an icon/picture frame, so px is right (CLAUDE.md). */
.thumb-strip-thumb {
    position: relative;
    width: 60px;
    height: 60px;
    border-radius: 10px;
    padding: 2px;
    border: 2px solid var(--border-color);
    box-sizing: border-box;
}

/* Circle variant — what list and collection covers have always worn. A product
   photo keeps the square default: it is rectangular, and a circle crops its
   sides off. Frame AND image, or the square picture pokes out of the round
   frame at the corners. */
.thumb-strip-thumb--round,
.thumb-strip-thumb--round .thumb-strip-img { border-radius: 50%; }

.thumb-strip-img {
    width: 100%;
    height: 100%;
    /* 7px against the frame's 10: the frame insets the picture by 3px (2px
       border + 2px padding, less the 1px the curves share), so the inner curve
       has to be tighter to stay concentric. */
    border-radius: 7px;
    object-fit: cover;
    display: block;
    background: var(--grey-highlight-pale);
}
.thumb-strip-img--empty {
    display: flex;
    align-items: center;
    justify-content: center;
}

.thumb-strip-count {
    position: absolute;
    right: -4px;
    top: -2px;
    pointer-events: none;
}

.thumb-strip-title {
    font-size: var(--fs-xs);
    line-height: 1.2;
    text-align: center;
    /* Two lines, then ellipsis. One line cut most real list names mid-word;
       two hold them. A cell is a top-aligned flex column, so a second caption
       line grows that cell downward and leaves every thumb on one line. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    word-break: break-word;
}

@media (hover: hover) {
    /* The global a:hover in core.css underlines any non-button link; a cell is a
       thumbnail + caption, not running text, so the frame and caption colour ARE
       the hover signal. .thumb-strip-item:hover (0,2,0) outranks that rule (0,1,1). */
    .thumb-strip-item:hover { text-decoration: none; }
    .thumb-strip-item:hover .thumb-strip-thumb { border-color: var(--pink-highlight); }
    .thumb-strip-item:hover .thumb-strip-title { color: var(--pink-highlight); text-decoration: none; }
}

/* This cell is the page's active filter. Same signal as hover (pink frame
   + pink caption) made permanent — and deliberately OUTSIDE the hover media
   query, since touch devices need the state most. */
.thumb-strip-item.active .thumb-strip-thumb { border-color: var(--pink-highlight); }
.thumb-strip-item.active .thumb-strip-title { color: var(--pink-highlight); }

/* ─── Shop-summary modal (ShopSummaryContent) ───
   Modal-only styling; /browse's branding box keeps its compact logo-only
   .shop-delivery-* row (css/pages/browse.css). Here Delivery and Leasing
   each get their own row with the company's full name next to its logo,
   at slightly larger type than the /browse variant. */
.shop-summary-body .shop-summary-service-row {
    justify-content: flex-start;
    align-items: flex-start;
}
/* Companies wrap INSIDE this box, so overflow lines stay right of the label. */
.shop-summary-options {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px 12px;
    min-width: 0;
}
.shop-summary-service-label {
    font-size: var(--fs-base);
    color: var(--text-light);
    white-space: nowrap;
}
.shop-summary-company {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: var(--fs-base);
}
.shop-summary-company img {
    width: 18px;
    height: 18px;
    border-radius: 3px;
    object-fit: contain;
}
/* ─── Shop rating (ShopRating.js) ───
   Star summary line in the entity-header subtitle slot + the teleported
   review popover. .star-row / .rating-bar / .star-select are the standard
   rating elements (see /admin#showcase → "Star rating"). Fractional star
   fill = a gold overlay row clipped to avg/5 of the width. The popover is
   position:fixed at z-index 3000 (same layer as .cp-pop — above the
   stacked-modal 2011), teleported to <body> so modal ancestor
   transform/overflow can never clip it. */
.shop-rating {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    cursor: pointer;
    user-select: none;
}
.star-row {
    position: relative;
    display: inline-flex;
    color: var(--icon-faint);         /* empty-star outline */
    line-height: 0;
}
.star-row-base,
.star-row-fill {
    display: inline-flex;
    gap: 2px;
}
.star-row-fill {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    overflow: hidden;
    color: var(--star-gold);          /* filled stars */
}
.star-row-fill svg { flex: 0 0 auto; }
.shop-rating-avg {
    font-weight: 600;
    color: var(--text-color);
    font-size: var(--fs-md);
}
.shop-rating-count {
    color: var(--text-light);
    font-size: var(--fs-md);               /* match .shop-rating-avg — same visual weight as the number */
    white-space: nowrap;
}
/* Inside the review popover the count reads larger than the summary line. */
.shop-rating-pop .shop-rating-count { font-size: var(--fs-xl); }
@media (hover: hover) { .shop-rating:hover .shop-rating-count { color: var(--text-color); } }
/* …except the count trailing the popover title, which rides with the title. */
.shop-rating-pop .shop-rating-count-inline { font-size: var(--fs-md); font-weight: 400; }
/* Summary-line count reads as the affordance that opens the review popover. */
.shop-rating-count-link { color: var(--pink-highlight); font-weight: 600; }
@media (hover: hover) { .shop-rating:hover .shop-rating-count-link { color: var(--pink-highlight); text-decoration: underline; } }
/* No-rating CTA: reads as an action ("Rate it") rather than muted meta text. */
.shop-rating-leave { color: var(--pink-highlight); font-weight: 600; }
@media (hover: hover) { .shop-rating:hover .shop-rating-leave { color: var(--pink-highlight); text-decoration: underline; } }

.shop-rating-pop {
    position: fixed;
    z-index: var(--z-popover);                    /* same layer as .cp-pop, above modals */
    width: 300px;
    max-width: calc(100vw - 16px);
    background: var(--surface);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-lg);
    padding: 14px;
    display: flex;
    flex-direction: column;
    gap: 10px;
}
.shop-rating-pop-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    font-size: var(--fs-md);
    font-weight: 600;
    color: var(--text-color);
}
.rating-row {
    display: flex;
    align-items: center;
    gap: 10px;
}
.rating-row-label {
    flex: 0 0 122px;
    font-size: var(--fs-base);
    color: var(--text-color);
}
/* Progress-bar visual for a 0–5 average (fill width = avg/5). min-width so
   the bar never collapses in shrink-to-content hosts (e.g. the showcase). */
.rating-bar {
    flex: 1;
    min-width: 60px;
    height: 8px;
    border-radius: var(--radius-sm);
    background: var(--pill-grey-bg);
    overflow: hidden;
}
.rating-bar-fill {
    display: block;
    height: 100%;
    border-radius: var(--radius-sm);
    background: var(--star-gold);
}
.rating-row-val {
    flex: 0 0 26px;
    text-align: right;
    font-size: var(--fs-base);
    font-weight: 600;
    color: var(--text-color);
}
/* Interactive 1–5 selector — five star buttons, gold up to the chosen value. */
.star-select {
    display: inline-flex;
    gap: 4px;
}
.star-select-btn {
    background: none;
    border: none;
    padding: 2px;
    cursor: pointer;
    color: var(--icon-faint);
    line-height: 0;
}
.star-select-btn.on { color: var(--star-gold); }
.shop-rating-pop-foot {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 10px;
    margin-top: 2px;
}
/* Actions are equal halves, and a lone action keeps that same half so the
   averages view and the edit view present the same footprint. */
.shop-rating-pop-foot > * {
    flex: 0 0 calc(50% - 5px);
}

/* ─── Performance switcher ─── */
.stats-perf-tabs {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 18px;
}

.stats-perf-tab {
    background: var(--surface-track);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    padding: 8px 16px;
    font-family: inherit;
    font-size: var(--fs-base);
    font-weight: 500;
    color: var(--text-light);
    cursor: pointer;
    transition: background 0.15s, color 0.15s, border-color 0.15s;
}

@media (hover: hover) { .stats-perf-tab:hover  { color: var(--text-color); } }
.stats-perf-tab.active {
    background: var(--pink-highlight-pale);
    border-color: var(--pink-highlight);
    color: var(--pink-highlight);
}

/* ─── Shop Performance controls row (period left, filter right) ─── */
.stats-shop-controls {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 12px;
    margin-bottom: 18px;
}

.stats-filter-select {
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    padding: 7px 12px;
    font-family: inherit;
    font-size: var(--fs-base);
    color: var(--text-color);
    background: var(--surface);
    cursor: pointer;
}

/* ─── KPI card as a chart selector ─── */
.stats-kpi-card.clickable {
    cursor: pointer;
    transition: border-color 0.15s, box-shadow 0.15s;
}

@media (hover: hover) {
    .stats-kpi-card.clickable:hover {
        border-color: var(--pink-highlight);
    }
}

.stats-kpi-card.clickable.active {
    border-color: var(--pink-highlight);
}

/* ─── Table column alignment — generic utilities ─────────────────────────────
   One alignment vocabulary for EVERY table (.admin-table, .stats-product-table,
   user-facing tables). They live here, not admin.css, so pages that don't load
   admin.css get them too. Apply on <th>/<td>.

   !important is deliberate: a table's base header rule (e.g. .admin-table th
   { text-align:left }, 0,1,1) out-specifies a bare .col-center (0,1,0) and
   would otherwise win on <th>. Utilities are the final word on alignment. */
.col-left   { text-align: left !important; }
.col-center { text-align: center !important; }
.col-right  { text-align: right !important; }
.col-nowrap { white-space: nowrap !important; }

/* ─── Input with trailing action button(s) ───────────────────────────────────
   A text/url input with trailing action buttons (open-in-tab, copy, show/hide,
   clear). Backs the <url-field> component (UrlField.js) and a few raw call sites
   (password eye, crawler test bars). Moved here from admin.css so the component
   works on any page. The .selector-test-bar layout override stays in admin.css.

   FLEX-WRAPPER model (same as .search-field, incl. .search-field--inline — #140): the
   WRAPPER owns the border + height; the input is a borderless flex child; the
   buttons sit in-flow to its right behind one divider. No absolute positioning.
   The wrapper shell itself is the shared fold near .search-wrapper (#141). */
/* Borderless, fills the wrapper. !important on border/height defeats the higher-
   specificity .form-row input[type=…] base (0,2,1) when a field sits in a form
   row (e.g. the proxy-service password) — the wrapper carries the chrome now. */
.input-with-actions input {
    flex: 1;
    min-width: 0;
    align-self: stretch;
    border: none !important;
    height: auto !important;
    padding: 0 10px;
    font-family: inherit;
    font-size: var(--fs-md);
    outline: none;
    background: transparent;
}
/* border:none resets the default UA <button> border so a button action (copy,
   password show/hide) matches the <a> link action. cursor:pointer so buttons
   feel clickable. In-flow flex child — 28×28, pink (affirmative actions). */
.input-action-btn { flex: none; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: var(--surface); border: none; border-radius: var(--radius-sm); color: var(--pink-highlight); padding: 0; box-sizing: border-box; cursor: pointer; transition: background 0.12s, color 0.12s; }
/* Hover grows a soft pill in the button's own colour family: pink-pale for the
   affirmative actions (copy / open / search), neutral grey for the clear ✕. */
@media (hover: hover) { .input-action-btn:hover { color: var(--pink-highlight-dark, #8a0058); background: var(--pink-highlight-pale); } }
.input-action-btn:disabled { color: var(--icon-faint); background: none; cursor: not-allowed; }
/* Muted clear/reset variant — a grey icon in a small filled grey circle (vs the
   pink affirmative actions). Single source of truth for every search clear ✕:
   SearchField (standard, inline and hero looks) via .input-action-btn--clear AND
   the MultiSelect dropdown's overlay clear (.multi-select-search-clear — which
   adds only its absolute positioning, in the block below). */
.input-action-btn--clear,
.multi-select-search-clear {
    width: 22px;
    height: 22px;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.06);
    color: var(--text-light);
}
@media (hover: hover) {
    .input-action-btn--clear:hover,
    .multi-select-search-clear:hover { color: var(--text-color); background: var(--border-color); }
}

/* ── MultiSelect — reusable checkbox dropdown (MultiSelect.js) ──────────────
   Used cross-page (admin shop pickers/filters, shop-owner "Shops" page). The
   grey-ghost clear ✕ colour/hover it shares lives in the rule above. */
.multi-select-dropdown { position: relative; }
/* Anchor the panel to the button only — so it drops under the trigger, not
   under the selected-pills that sit below it in the same dropdown. */
.multi-select-control { position: relative; }
.multi-select-trigger {
    display: flex; align-items: center; justify-content: space-between; gap: 8px;
    width: 100%; min-height: var(--control-height); box-sizing: border-box; padding: 0.3em 12px; border: 1px solid var(--border-color); border-radius: var(--radius-control);
    background: var(--surface); cursor: pointer; font-size: var(--fs-base); text-align: left;
}
.multi-select-trigger:not(:disabled):hover { border-color: var(--pink-highlight); }
.multi-select-trigger:disabled { opacity: 0.6; cursor: not-allowed; background: var(--bg-light); }
.multi-select-placeholder { color: var(--text-placeholder); }
/* <multi-select pills-inside>: the box wraps its pills; the toggle button takes
   the leftover width, so clicking empty space in the box still opens it. */
.multi-select-trigger--pills { flex-wrap: wrap; justify-content: flex-start; gap: 6px; cursor: default; }
.multi-select-toggle {
    flex: 1 0 auto; align-self: stretch; display: flex; align-items: center; justify-content: flex-end; gap: 8px;
    padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer;
}
.multi-select-toggle .multi-select-placeholder { flex: 1; }
.multi-select-panel {
    position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
    width: 100%; min-width: 220px; box-sizing: border-box;
    /* Default: the panel scrolls itself — used by the many hand-rolled
       multi-selects whose options sit directly inside the panel. */
    max-height: 200px; overflow-y: auto; background: var(--surface);
    border: 1px solid var(--border-color); border-radius: var(--radius-control); margin-top: 4px;
    /* Layered shadow so the panel clearly floats above the form inputs below. */
    box-shadow: var(--shadow-panel);
}
/* <multi-select> component panels wrap their options in .multi-select-scroll.
   There, the inner div scrolls and the panel is clipped (overflow:hidden) so the
   scrollbar can't square off the rounded corners. */
.multi-select-panel:has(.multi-select-scroll) { overflow: hidden; max-height: none; }
/* <multi-select> panels are teleported to <body> and fixed-positioned from the
   trigger rect (MultiSelect.js sets top/bottom/left/width inline), so they
   escape overflow-clipping ancestors like .modal-body. The 4px gap
   is baked into the JS coordinates, hence margin 0 here. */
.multi-select-panel--fixed {
    position: fixed; top: auto; left: auto; right: auto;
    margin: 0;
    z-index: var(--z-panel); /* above stacked modals (.modal-stacked content is 2011) */
}
/* Opt-in wide panel (<multi-select panel-wide>): the option label may wrap
   instead of being clipped at the trigger's width. Long values — a category
   path, a full product title — are otherwise indistinguishable from each other
   because every row is cut at the same place. */
.multi-select-panel--wide .multi-select-option { align-items: flex-start; }
.multi-select-panel--wide .multi-select-option span {
    flex-shrink: 1;
    white-space: normal;
    overflow-wrap: anywhere;
}
/* overscroll-behavior:contain keeps a list swipe from chaining into a page
   scroll (which the panel's outside-scroll handler would read as a close). */
.multi-select-scroll { max-height: 200px; overflow-y: auto; overscroll-behavior: contain; }
/* Fixed header above the scroll area (so it never overlaps the rounded corners). */
.multi-select-search {
    position: relative;
    background: var(--surface); padding: 8px; border-bottom: 1px solid var(--border-color);
}
.multi-select-search input {
    width: 100%; box-sizing: border-box; padding: 6px 28px 6px 10px;
    border: 1px solid var(--border-color); border-radius: var(--radius-control); font-size: var(--fs-base);
}
.multi-select-search input:focus { outline: none; border-color: var(--pink-highlight); }
/* The clear ✕ circle (size, fill, colour, hover) is shared from
   .input-action-btn--clear (above). This rule adds only what's unique to the
   MultiSelect dropdown: absolute overlay on the dense in-panel filter input. */
.multi-select-search-clear {
    position: absolute;
    right: 13px;
    top: 50%;
    transform: translateY(-50%);
    display: flex; align-items: center; justify-content: center;
    border: none;
    cursor: pointer; padding: 0;
}
.multi-select-empty { padding: 10px 12px; color: var(--text-light); font-size: var(--fs-base); }
/* Scoped under .multi-select-panel so it outranks `.form-row label`
   (0,2,0 > 0,1,1), which would otherwise force these option labels to
   display:block / bold / grey / margin-bottom when the control sits in a form. */
.multi-select-panel .multi-select-option {
    display: flex; align-items: center;
    margin: 0; padding: 8px 12px;
    cursor: pointer; font-size: var(--fs-base); font-weight: 400; color: var(--text-color);
}
.multi-select-option:hover { background: var(--bg-light); }
.multi-select-option input[type="checkbox"] { accent-color: var(--pink-highlight); margin: 0 10px 0 0; width: 16px; height: 16px; flex-shrink: 0; }
.multi-select-option .favicon-sm { margin-right: 8px; flex-shrink: 0; }
.multi-select-option span { flex-shrink: 0; }
/* Fixed footer below the scroll area (mirrors .multi-select-search up top):
   All / Clear bulk-selection links, shown only when the list is long enough. */
.multi-select-footer {
    display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px;
    background: var(--surface); padding: 8px 12px; border-top: 1px solid var(--border-color);
}
/* "Selected only" sits right, at the links' type size, not .checkbox-container's. */
.multi-select-footer > .checkbox-container { margin-left: auto; font-size: var(--fs-base); }
.multi-select-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
/* Trailing actions — single OR grouped — always sit in an .input-actions row
   behind one divider (the group's left border). border-radius:4px softens the
   divider's ends; gap + padding-left give the even 4px rhythm and the wrapper's
   padding-right is the matching right inset (top/bottom fall out of centering). A
   lone button uses the same wrapper, so its divider matches the multi-button case
   (the divider lives on the group, never on the button itself). */
.input-actions { display: flex; align-items: center; gap: 4px; background: var(--surface); border-left: 1px solid var(--border-color); border-radius: var(--radius-sm); padding-left: 4px; }

/* ─── Product Performance table ─── */
/* Scroll container for the Product / User Performance tables: vertical scroll
   on desktop (capped so the period switcher stays visible) and horizontal
   scroll on mobile (wide tables) instead of breaking the modal layout. */
.stats-table-scroll {
    max-height: 50vh;
    overflow: auto;
}

.stats-product-table {
    width: 100%;
    border-collapse: collapse;
    font-size: var(--fs-md);
}

.stats-product-table thead th {
    text-align: left;
    padding: 8px 12px;
    font-size: var(--fs-base);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-light);
    border-bottom: 1px solid var(--border-color);
}

/* Keep the header visible while the body scrolls vertically. Opaque bg so
   rows don't bleed through the sticky row. */
.stats-table-scroll .stats-product-table thead th {
    position: sticky;
    top: 0;
    background: var(--surface);
    z-index: 1;
}

.stats-product-table tbody td {
    padding: 10px 12px;
    border-bottom: 1px solid var(--border-color);
    vertical-align: middle;
}

.stats-product-number-col,
.stats-product-table thead th.stats-product-number-col {
    text-align: right;
    white-space: nowrap;
    font-weight: 600;
    color: var(--text-color);
}

.stats-product-cell {
    display: flex;
    align-items: center;
    gap: 12px;
}

.stats-product-thumb {
    width: 44px;
    height: 44px;
    flex: 0 0 44px;
    object-fit: contain;
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius);
    background: var(--surface);
}

.stats-product-meta {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.stats-product-brand {
    font-size: var(--fs-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    color: var(--text-light);
}

.stats-product-title {
    color: var(--text-color);
}

.stats-product-price {
    font-size: var(--fs-base);
    font-weight: 600;
    color: var(--pink-highlight);
}

/* Mobile: tighten spacing so the table fits the viewport (titles wrap at
   every width). */
@media (max-width: 768px) {
    .stats-product-table thead th,
    .stats-product-table tbody td {
        padding: 8px;
    }

    .stats-product-cell {
        gap: 8px;
    }
}

/* ─── User (Star) Performance roster — reuses .stats-product-table ─── */
.stats-roster-avatar {
    border-radius: 50%;       /* round, vs the square product thumb */
}

/* px width to stay on the same column as the 44px .stats-roster-avatar photos;
   min-height because the initial inside is text. */
.stats-roster-monogram {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    min-height: 44px;
    flex: 0 0 44px;
    background: var(--pink-highlight);
    color: #fff;
    font-size: var(--fs-md);
    font-weight: 600;
}

/* Connected-platform icons, stacked above the field label */
.stats-roster-connected {
    display: flex;
    /* The cell's .col-center sets text-align, which flex children ignore. */
    justify-content: center;
    gap: 6px;
    margin-bottom: 4px;
    color: var(--text-light);
}

/* ── Keyboard focus ring (WCAG 2.4.7 AA, 2026-06-13) ──────────────────────────
   A visible brand-pink ring for keyboard / assistive-tech users. `:focus-visible`
   excludes mouse clicks for buttons/links, so pointer users keep the borderless
   look while keyboard users get a clear focus indicator. Placed LAST in the
   cascade so it overrides the scattered `:focus { outline: none }` rules at equal
   specificity; the .btn-* variant selectors carry higher specificity already.

   Form controls (input/select/textarea) are deliberately NOT included: they
   already signal focus with the brand-pink border (core.css), and unlike buttons
   they match :focus-visible on mouse click too — so an outline here would both
   double up and fire for pointer users. */
a:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,
[tabindex]:focus-visible,
.btn:focus-visible,
.btn-primary:focus-visible, .btn-primary-pale:focus-visible,
.btn-blue:focus-visible, .btn-blue-pale:focus-visible,
.btn-green:focus-visible, .btn-green-pale:focus-visible,
.btn-grey:focus-visible, .btn-red-pale:focus-visible,
.chip:focus-visible,
.icon-favorite:focus-visible,
.admin-hint-btn:focus-visible,
.hint-copy:focus-visible,
.close-btn:focus-visible {
    outline: 2px solid var(--pink-highlight);
    outline-offset: 2px;
}

/* Attach-media modal: the confirmation line after a save (live vs pending). */
.pm-attach-done {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0;
    font-size: var(--fs-base);
    color: var(--text-color);
}

/* Attach-media modal: the two short selects (scope + market) sit side by side
   on desktop and stack on narrow screens. .tp-field/.tp-label/.tp-input do the
   rest — the same field anatomy as the Track-price modal. */
.pm-attach-row {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    /* Column gap only — the .tp-field children own the vertical rhythm, so a
       row gap here would double it when the grid collapses to one column. */
    column-gap: 12px;
}
@media (max-width: 600px) {
    .pm-attach-row { grid-template-columns: 1fr; }
}

/* Product-media modal: empty-tab line + the author's "Add …" button. Only
   admins / the shop's managers ever see an empty tab, so the message doubles
   as the prompt for the button under it. */
.pm-tab-empty {
    margin: 0 0 12px;
    font-size: var(--fs-base);
    color: var(--text-light);
}
.pm-add-btn { margin-top: 14px; }

/* Author edit pencil, over a video tile or an article card. Same dark scrim as
   the video duration chip so the family reads as one. Top-LEFT so it clears the
   duration chip (top-right). */
.pm-media-edit {
    position: absolute;
    top: 6px;
    left: 6px;
    z-index: 3;
    width: 26px;
    height: 26px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.55);
    color: #fff;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
}
@media (hover: hover) {
    .pm-media-edit:hover { background: var(--pink-highlight); }
}

/* ── Add wizard: New content vs Existing content ─────────────────────────── */
/* The add wizard: two equal choices, "New content" vs "Existing content".
   Side by side on desktop — they are alternatives, and a row reads as a fork
   in the road where a column reads as a first option with an afterthought
   under it. Stacked below 1201px, the same breakpoint at which the shell
   stops being a centered dialog and becomes a full-width bottom sheet. */
.pm-add-choose {
    display: grid;
    gap: 12px;
}
@media (min-width: 1201px) {
    .pm-add-choose { grid-template-columns: 1fr 1fr; }
}
.pm-add-choice {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    text-align: left;
    padding: 16px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    background: var(--surface);
    cursor: pointer;
    transition: border-color .12s, background .12s;
}
.pm-add-choice:hover { border-color: var(--pink-highlight); background: var(--pink-highlight-pale); }
.pm-add-choice-title { font-weight: 600; font-size: var(--fs-md); color: var(--text-color); }
.pm-add-choice-sub { font-size: var(--fs-sm); color: var(--text-light); }

/* ── Reuse (library) picker: a neat selectable list of shop media ─────────── */
.pm-lib-search { display: flex; gap: 8px; }
.pm-lib-search .tp-input { flex: 1; }
.pm-lib-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.pm-lib-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 8px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    cursor: pointer;
    transition: border-color .12s, background .12s;
}
.pm-lib-row:hover { border-color: var(--pink-highlight); }
.pm-lib-row.is-selected { border-color: var(--pink-highlight); background: var(--pink-highlight-pale); }
.pm-lib-check { flex: 0 0 auto; width: 18px; display: flex; align-items: center; justify-content: center; }
.pm-lib-checkbox { width: 16px; height: 16px; border: 2px solid var(--border-color-dark, #bbb); border-radius: 4px; }
.pm-lib-thumb {
    position: relative;
    flex: 0 0 64px;
    width: 64px;
    height: 48px;
    border-radius: var(--radius-control);
    overflow: hidden;
    background: #000;
}
.pm-lib-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pm-lib-dur {
    position: absolute;
    right: 3px;
    bottom: 3px;
    padding: 0 4px;
    border-radius: 3px;
    background: rgba(0, 0, 0, 0.75);
    color: #fff;
    font-size: var(--fs-3xs);
    font-weight: 600;
}
.pm-lib-meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.pm-lib-title {
    font-size: var(--fs-sm);
    font-weight: 600;
    color: var(--text-color);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}
.pm-lib-source { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); color: var(--text-light); }

/* Edit-media: the saved media image sits left of the title. The image keeps its
   3:2 shape at a quarter of the modal, and the title box stretches to match
   its height rather than guessing a row count. */
.pm-edit-title-row {
    display: flex;
    align-items: stretch;
    gap: 12px;
    margin-bottom: 16px;
}
.pm-edit-title-row .pm-edit-thumb { flex: 0 0 25%; width: 25%; }
.pm-edit-title-row .tp-field {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    margin-bottom: 0;
}
.pm-edit-title-row textarea.tp-input { flex: 1; min-height: 0; }

/* ── Toggle switch ────────────────────────────────────────────────────────── */

.admin-toggle {
    appearance: none;
    -webkit-appearance: none;
    width: 36px;
    height: 20px;
    background: #ccc;
    border-radius: 10px;
    cursor: pointer;
    position: relative;
    transition: background 0.2s;
    flex-shrink: 0;
}

.admin-toggle:checked { background: var(--pink-highlight); }

.admin-toggle::after {
    content: '';
    position: absolute;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: #fff;
    top: 2px;
    left: 2px;
    transition: left 0.2s;
    box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}

.admin-toggle:checked::after { left: 18px; }

/* ── Edit-media (card side): inline AI summary + delete ───────────────────── */
.pm-edit-summary .pm-summary-text { margin: 0 0 8px; }
.pm-edit-summary-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.pm-edit-applyall { margin-top: 4px; }
.pm-edit-delete-confirm { flex: 1; font-size: var(--fs-sm); color: var(--error-color); align-self: center; }
/* Quiet destructive button reuses .btn; only the colour changes. */
.pm-btn-danger-quiet { color: var(--error-color); margin-right: auto; }
.pm-btn-danger { background: var(--error-color); border-color: var(--error-color); }
.pm-btn-danger:hover { background: #c62030; border-color: #c62030; }

/* ── Video theatre (stacked modal) ────────────────────────────────────────
   The same carousel component with one slide, sized by VIEWPORT HEIGHT rather
   than slide width so the portrait tile is as big as the screen allows without
   the modal ever needing to scroll. */
.pm-theatre-modal .modal-dialog,
.pm-theatre-modal.modal-dialog { max-width: min(94vw, 520px); }

.pm-theatre-body { padding-bottom: 16px; }

/* A video title is a caption the uploader wrote, not a heading we chose: a
   TikTok description runs to hundreds of characters with a tail of hashtags
   (see media 23), and at full length it pushed the player off the screen. Two
   lines, then ellipsis — the untruncated text stays available as the tile's own
   title/link underneath. */
.pm-theatre-modal .modal-header-text h3 {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    word-break: break-word;
}

.pm-video-splide--theatre .splide__slide { width: 100% !important; }

/* Splide never mounted — the admin theatre loads the carousel component but not
   the vendor library (one big page, and a carousel of ONE slide is pointless).
   Without the library the track is a bare <ul>, so it needs the list reset
   Splide's own stylesheet would have supplied. Keyed off `is-initialized`, the
   class Splide stamps on the root when it mounts, so this also catches a failed
   mount on the public side instead of leaving bullets in the modal. */
.pm-video-splide--theatre:not(.is-initialized) .splide__list {
    display: block;
    list-style: none;
    margin: 0;
    padding: 0;
}
.pm-video-splide--theatre:not(.is-initialized) .splide__track { overflow: visible; }

/* Height-driven, so the tile keeps the 9:16 shape of the format instead of
   stretching to the modal's width and letterboxing a portrait video. */
.pm-video-splide--theatre .pm-video-frame,
.pm-video-splide--theatre .pm-video-thumb {
    height: min(68vh, 640px);
    width: auto;
    aspect-ratio: 9 / 16;
    margin: 0 auto;
}

/* A landscape video (a normal YouTube upload rather than a Short) fills the
   modal's width at 16:9 instead of sitting as a band inside a portrait box. */
.pm-video-splide--theatre .pm-video-frame.is-landscape,
.pm-video-splide--theatre .pm-video-thumb.is-landscape {
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 9;
}

/* Title and byline stay, one step larger — this is now the page's subject. */
.pm-video-splide--theatre .pm-video-title { font-size: var(--fs-md); -webkit-line-clamp: 2; }

/* The play overlay and duration chip are positioned against the tile. */
.pm-video-slide { position: relative; }

/* ── LinkedProducts — the products one media shows on ─────────────────────
   Since a video/article became ONE row spanning many products, both edit
   surfaces (the card's Edit modal and admin #shops → Media) needed to show a
   SET of products where they used to show one. It reuses the Share-a-list
   strip wholesale (.share-thumbs-row / .share-thumb / .share-thumb-more) so the
   two mean the same thing visually; only the authoring affordances — an ✕ per
   thumb and an "add" tile — are new here. Lives in components.css, not
   admin.css, because the card modals cannot see admin.css (same trap as
   .admin-toggle and SecondaryNavTabs). */
.pm-linked { margin-bottom: 16px; }

.pm-linked-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 8px;
}

/* Right-hand action group of the header. The header is space-between, so a
   second control has to share ONE box with "Show all" or it would be pushed
   over against the label. Holds the optional #head-end slot. */
.pm-linked-head-actions {
    display: inline-flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
}
/* .tp-label owns the type; the count badge rides with the text, so the label
   itself becomes the flex line rather than a bare string. */
.pm-linked-label {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    margin: 0;
}
.pm-linked-empty { font-size: var(--fs-sm); margin: 0; }

/* Left-aligned inside a form (the share modal centres its strip, which is right
   for a one-off preview and wrong for a field), and wrapping: an article can sit
   on a dozen products, which is more than one row holds. */
.pm-linked-row { justify-content: flex-start; flex-wrap: wrap; row-gap: 8px; }

/* The ✕ hangs off the thumb's corner, so the thumb needs to be the containing
   block. Not a .share-thumb itself — that class is on the <img> inside. */
.pm-linked-thumb {
    position: relative;
    display: inline-flex;
    flex: 0 1 auto;
    line-height: 0;
}
.pm-linked-thumb .share-thumb { flex: 0 0 auto; width: 75px; max-width: 75px; }

.pm-linked-remove {
    position: absolute;
    top: -6px;
    right: -6px;
    width: 20px;
    height: 20px;
    padding: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--border-color, #e5e5e5);
    border-radius: 50%;
    background: var(--surface, #fff);
    color: var(--text-light);
    cursor: pointer;
    /* Above the neighbouring thumb, which would otherwise clip the overhang. */
    z-index: 1;
}
.pm-linked-remove:hover { color: var(--error-color); border-color: var(--error-color); }

/* The "add" tile is a .share-thumb box, so it lines up with the row without a
   second set of size rules. */
.pm-linked-add {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 1px dashed var(--pink-highlight);
    color: var(--pink-highlight);
    background: var(--pink-highlight-pale);
    cursor: pointer;
}
.pm-linked-add:hover { background: var(--surface); }

/* The compact (table) variant: one row of equal boxes and nothing else — a
   leading "Show all" tile carrying the count, then up to `max` thumbs, then
   "+N more". A per-row label and a separate button would repeat 50 times down
   the page; the tile is both. */
.pm-linked-showall {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 1px;
    background: var(--pink-highlight-pale);
    border-color: var(--pink-highlight-pale);
    color: var(--pink-highlight);
    text-decoration: none;
    line-height: 1;
}
/* Anchors inside admin tables are painted link-blue by the global `a` rule and
   underlined on hover — this tile is a control, so it keeps its own colour. */
.pm-linked-showall:hover,
.admin-table a.pm-linked-showall,
.admin-table a.pm-linked-showall:hover { color: var(--pink-highlight); text-decoration: none; }
.pm-linked-showall:hover { background: var(--surface); }
.pm-linked-showall-n { font-size: var(--fs-3xs); font-weight: 700; }

/* In an admin table cell the strip is a compact read-only summary: smaller
   boxes, single row, no header. */
.admin-table .pm-linked { margin-bottom: 0; }
.admin-table .pm-linked-row { gap: 5px; flex-wrap: nowrap; }
.admin-table .pm-linked-thumb .share-thumb,
.admin-table .pm-linked-row .share-thumb { width: 40px; max-width: 40px; flex: 0 0 auto; }
.admin-table .pm-linked-row .share-thumb-more { font-size: 0.6rem; }

/* ── FAQ accordion (FaqList.js) ───────────────────────────────────────────
   Lived in pages/home.css until /signup grew the same block in its pitch
   panel. The rules here are the accordion itself — hairline rows, the +/−
   glyph, the animated answer; each host page owns its own layout around it
   (home.css's two-column .home-faq-grid, auth.css's purple panel). */
.faq-item {
    border-bottom: 1px solid var(--border-color);
    overflow: hidden;
}
/* Question row is a real <button> (a11y) reset to look like the old summary. */
.faq-q {
    width: 100%;
    cursor: pointer;
    padding: 15px 2px;
    font-family: inherit;
    font-size: var(--fs-lg);
    font-weight: 600;
    color: var(--text-color);
    background: none;
    border: none;
    text-align: left;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
}
/* Animated +/− toggle (two bars): on open the whole icon spins 180° while the
   vertical bar rotates 90° and fades — "+" morphs smoothly into "−". */
.faq-icon {
    position: relative;
    flex-shrink: 0;
    width: 1em;
    height: 1em;
    transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.faq-icon::before,
.faq-icon::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    background: var(--pink-highlight);
    border-radius: 1px;
    transition: transform 0.3s, opacity 0.3s;
}
.faq-icon::before { width: 0.75em; height: 2px; transform: translate(-50%, -50%); }   /* horizontal */
.faq-icon::after  { width: 2px; height: 0.75em; transform: translate(-50%, -50%); }   /* vertical */
.faq-item.open .faq-icon { transform: rotate(180deg); }
.faq-item.open .faq-icon::after { transform: translate(-50%, -50%) rotate(90deg); opacity: 0; }
/* Smooth open/close of the answer via the grid-template-rows 0fr→1fr technique
   (works in every browser, unlike <details> content-height animation). The inner
   wrapper clips the overflow while the row collapses. */
.faq-answer {
    display: grid;
    grid-template-rows: 0fr;
    transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.faq-item.open .faq-answer { grid-template-rows: 1fr; }
.faq-answer-inner { overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
    .faq-icon, .faq-icon::before, .faq-icon::after,
    .faq-answer { transition: none; }
}
/* The open question carries the accent, same as its +/− glyph. */
.faq-item.open .faq-q { color: var(--pink-highlight); }
.faq-item p {
    padding: 0 2px 16px;
    color: var(--text-light);
    line-height: 1.55;
    font-size: var(--fs-md);
}
/* Chevron + label expander under the list; colour and reset come from
   .btn-link, this only lays the two out. */
.faq-more {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    margin-top: 6px;
    padding: 0.5em 0;
    font-size: var(--fs-md);
    font-weight: 600;
}

/* ── Motion opt-out ──────────────────────────────────────────────────────
   Every animated rule in this file, behind the one query that asks. This
   sheet already carried the pattern for a handful of components; the rest
   were simply never enumerated. Listed selector by selector rather than as
   a `*` reset, because a universal reset only beats these rules with
   !important, which this project does not add.
   ──────────────────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
    .btn, .btn-primary, .btn-primary-pale, .btn-green, .btn-green-pale, .btn-blue, .btn-blue-pale, .btn-red-pale, .btn-grey, .btn-neutral,
    .btn-link-soft,
    .chip,
    .action-btn.blue,
    .action-btn.pink-filled,
    .product-card-remove-overlay,
    .product-card-success-overlay,
    .tp-confirm,
    .tp-confirm-bell,
    .tp-confirm-mark-fill,
    .tp-confirm-off,
    .tp-confetti > span,
    .product-img,
    .icon-favorite,
    .freshness-tooltip,
    .pm-circle,
    .splide__arrow,
    .search-suggest-item,
    .search-suggest-x,
    .checkbox-container input, .filter-option input[type="checkbox"],
    .checkbox-container input::before, .filter-option input[type="checkbox"]::before,
    .notifications-overlay,
    .notifications-overlay.active,
    .notifications-modal,
    .notif-item,
    .notif-dismiss,
    .hot-products-sidebar,
    .celebrities-overlay,
    .hot-products-sidebar, .celebrities-sidebar,
    .celeb-card,
    .modal-wrapper.modal-stacked.modal-dialog,
    .standard-close-btn,
    .hint-tip,
    .list-item,
    .filter-section-chevron,
    .filter-option,
    .filter-range-input::-webkit-slider-thumb,
    .filter-price-input,
    .form-control,
    .share-col-row,
    .variants-check,
    .price-dot,
    .price-dot-latest,
    .price-checking,
    .price-updated,
    .compact-icon-btn,
    .stat-card.clickable,
    .toggle-pill,
    .secondary-nav-tab,
    .stats-chart-canvas .apexcharts-marker[default-marker-size="4"],
    .stats-perf-tab,
    .stats-kpi-card.clickable,
    .input-action-btn,
    .pm-add-choice,
    .pm-lib-row,
    .admin-toggle,
    .admin-toggle::after {
        transition: none;
        animation: none;
    }

    /* Progress feedback, not decoration: a spinner that stops spinning
       stops saying "still working". Its transitions go; its spin stays. */
    .loading-spinner {
        transition: none;
    }
}

/* ══════════════════════════════════════════════════════════════════════════
   Carousel rail chrome — track breathing room + edge-fade arrows
   ══════════════════════════════════════════════════════════════════════════
   Shared by the homepage Selection rails (.home-rail-splide) and /browse's
   shop-category strip (.cat-carousel-splide). Moved here byte-identical from
   pages/home.css on 2026-08-28 when the second surface adopted it — the same
   move .celeb-card made when it stopped belonging to one page. Adding a third
   carousel to this list is now a one-line change; forking a second copy is not.

   Vendor splide.min.css puts `overflow: hidden` on .splide__track, and a slide
   is exactly as tall as the track — so anything a card paints OUTSIDE its own
   box on hover is sliced off. The star tile lifts (.celeb-card:hover →
   translateY(-2px) + a 0 4px 12px shadow), which cost it its whole 1px top
   border. A few px of breathing room inside the track gives the lift somewhere
   to land: 4px up (2px travel + the shadow's 6px blur reach) and 8px down (the
   shadow's +4px y-offset, less the 2px the card rose, plus that same blur).
   VERTICAL ONLY — horizontal padding here would shrink the width Splide
   measures for perPage/fixedWidth and eat the next-card peek. */
.home-rail-splide .splide__track,
.cat-carousel-splide .splide__track,
.product-group .splide__track,
.cmp-splide .splide__track { padding: 4px 0 8px; }   /* group slides: .compact-card's shadow was cut flush (2026-09-24) */

.home-rail-splide .splide__slide,
.cat-carousel-splide .splide__slide { height: auto; }

/* Edge-fade arrows (design chosen 2026-07-10): no button chrome — each arrow is
   a full-height white→transparent gradient at the carousel edge that dissolves
   the peeking card, with a borderless chevron on top. Disabled (at the start /
   end) → hidden, so a fade only shows on the side that still has more to scroll. */
.home-rail-splide .splide__arrow,
.cat-carousel-splide .splide__arrow,
.cmp-splide .splide__arrow {
    top: 0;
    height: 100%;
    width: 56px;
    transform: none;
    border-radius: 0;
    box-shadow: none;
    opacity: 1;
    display: flex;
    align-items: center;
    transition: opacity 0.2s ease;
}
.home-rail-splide .splide__arrow--prev,
.cat-carousel-splide .splide__arrow--prev,
.cmp-splide .splide__arrow--prev {
    left: 0;
    justify-content: flex-start;
    padding-left: 2px;
    background: linear-gradient(90deg, var(--surface) 32%, rgba(255, 255, 255, 0) 100%);
}
.home-rail-splide .splide__arrow--next,
.cat-carousel-splide .splide__arrow--next,
.cmp-splide .splide__arrow--next {
    right: 0;
    justify-content: flex-end;
    padding-right: 2px;
    background: linear-gradient(270deg, var(--surface) 32%, rgba(255, 255, 255, 0) 100%);
}
.home-rail-splide .splide__arrow svg,
.cat-carousel-splide .splide__arrow svg,
.cmp-splide .splide__arrow svg {
    width: 1.5em;
    height: 1.5em;
    fill: var(--text-color);
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.18));
    transition: fill 0.15s ease;
}
@media (hover: hover) {
    .home-rail-splide .splide__arrow:hover svg,
    .cat-carousel-splide .splide__arrow:hover svg,
    .cmp-splide .splide__arrow:hover svg { fill: var(--pink-highlight); }
}
.home-rail-splide .splide__arrow:disabled,
.cat-carousel-splide .splide__arrow:disabled,
.cmp-splide .splide__arrow:disabled { opacity: 0; pointer-events: none; }

@media (prefers-reduced-motion: reduce) {
    .home-rail-splide .splide__arrow,
    .cat-carousel-splide .splide__arrow,
    .cmp-splide .splide__arrow,
    .home-rail-splide .splide__arrow svg,
    .cat-carousel-splide .splide__arrow svg,
    .cmp-splide .splide__arrow svg {
        transition: none;
        animation: none;
    }
}

/* ── Avatar picker ────────────────────────────────────────────────────────
   The <avatar-picker> modal's own furniture: the built-in-avatar / photo
   grids, the section labels above them, and the round crop viewport of the
   step that follows an upload. It lived in pages/profile.css while /profile
   was the only page that could change a picture; admin's user modal renders
   the same component, so it belongs here instead — unchanged, just moved.
   ──────────────────────────────────────────────────────────────────────── */

/* The control that opens it: the picture itself, with a pencil chip cueing
   that it can be changed. /profile's identity hero and admin's user modal
   both render it, so it carries no page prefix. */
.avatar-edit {
    position: relative;
    display: inline-block;   /* hugs the disc, so the badge pins to ITS corner outside a flex row */
    flex-shrink: 0;
    cursor: pointer;
    border-radius: 50%;
}
/* An image frame, not a label — px is right, nothing inside it is type. */
.avatar-edit-disc {
    width: 80px;
    height: 80px;
    border-radius: 50%;
    border: 2px solid var(--border-color);
    background-size: cover;
    background-position: center;
}
.avatar-edit .avatar-edit-disc { transition: box-shadow 0.15s ease; }
.avatar-edit:focus-visible { outline: none; }
.avatar-edit:hover .avatar-edit-disc,
.avatar-edit:focus-visible .avatar-edit-disc {
    box-shadow: 0 0 0 3px var(--pink-highlight-pale);
}
.avatar-edit-badge {
    position: absolute;
    right: -2px;
    bottom: -2px;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 26px;
    height: 26px;
    background: var(--pink-highlight);
    color: var(--surface);
    border: 2px solid var(--surface);
    border-radius: 50%;
}

@media (prefers-reduced-motion: reduce) {
    .avatar-edit .avatar-edit-disc {
        transition: none;
        animation: none;
    }
}

.avatar-section-label {
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-light);
    font-weight: 600;
    margin: 0 0 10px;
}
.avatar-grid + .avatar-section-label { margin-top: 22px; }
.avatar-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
    gap: 15px;
    margin-top: 20px;
}

/* A <button>, so the picker is operable from the keyboard — hence the padding
   reset. Equal-sided and holding a picture, never type, so px is right here. */
.avatar-option {
    width: 80px;
    height: 80px;
    padding: 0;
    border-radius: 50%;
    cursor: pointer;
    border: 2px solid transparent;
    overflow: hidden;
    background: var(--bg-light);
}

.avatar-option img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.avatar-option:hover,
.avatar-option:focus-visible,
.avatar-option.selected {
    border-color: var(--pink-highlight);
}

.avatar-option.selected {
    box-shadow: 0 0 0 2px rgba(182, 3, 120, 0.3);
}

/* The "add a photo" tile: an .avatar-option holding an icon instead of a
   picture, dashed so it reads as an empty slot rather than a choice. */
.avatar-option-add {
    display: flex;
    align-items: center;
    justify-content: center;
    border-style: dashed;
    border-color: var(--border-color);
    color: var(--icon-faint);
    background: transparent;
}
.avatar-option-add:hover { color: var(--pink-highlight); }

/* Actions belonging to the section above them (Remove photo, Refresh). */
.avatar-actions {
    display: flex;
    gap: 8px;
    margin-top: 12px;
}

/* ── Avatar crop step ────────────────────────────────────────────
   A round viewport showing exactly the disc that will be saved: drag
   the picture inside it to frame, the slider zooms. The image is sized
   and offset from the script (the only genuinely dynamic values); the
   shape, the clipping and the grab affordance live here. */
.avatar-crop-view {
    position: relative;
    width: 100%;
    max-width: 260px;
    aspect-ratio: 1;
    margin: 0 auto;
    overflow: hidden;
    border-radius: 50%;
    background: var(--bg-light);
    cursor: grab;
    touch-action: none;   /* the drag is ours, not the browser's scroll */
}
.avatar-crop-view:active { cursor: grabbing; }
.avatar-crop-img {
    position: absolute;
    top: 50%;
    left: 50%;
    max-width: none;      /* the script sizes it; don't let a page rule shrink it */
    user-select: none;
    -webkit-user-drag: none;
}
.avatar-crop-zoom {
    display: flex;
    align-items: center;
    gap: 12px;
    max-width: 260px;
    margin: 18px auto 0;
}
.avatar-crop-zoom label { font-size: var(--fs-base); color: var(--text-light); }
.avatar-crop-zoom input[type="range"] {
    flex: 1;
    accent-color: var(--pink-highlight);
}

/* The result banner under a panel's own action — green by default, red with
   .is-error. Named for the sessions list it was written for, and still worn by
   it; here rather than in pages/profile.css because <sessions-panel> and
   <avatar-picker> are components, and the picker now reports from inside
   admin's user modal too. */
.session-feedback {
    margin-top: 14px;
    margin-bottom: 14px;
    padding: 10px 14px;
    border-radius: var(--radius-md);
    background: var(--pill-green-bg);
    color: var(--pill-green-text);
    font-size: var(--fs-base);
}
.session-feedback.is-error {
    background: var(--pill-red-bg);
    color: var(--pill-red-text);
}

/* ── Barcode scan modal content ───────────────────────────────────────────
   2026-05-21 redesign: square (1:1) camera viewport, centered qrbox, top-right
   control cluster (torch + camera switch), state overlays (lookup spinner /
   scan-again), small product tile grid, in-modal empty state. The library
   (html5-qrcode) injects its own <video> into the reader div; we wrap that in
   the aspect-ratio container so the camera fills the square cleanly. */

.barcode-scan-intro { margin-top: 0; margin-bottom: 14px; }

/* 1:1 viewport — capped to keep things readable on desktop, fills width on
   narrow screens. The library's reader DIV is the direct child; its <video>
   gets object-fit: cover so the camera frame fills the square edge-to-edge. */
.barcode-scan-camera-wrap {
    position: relative;
    aspect-ratio: 1 / 1;
    width: 100%;
    max-width: 420px;
    margin: 0 auto 14px;
    border-radius: 12px;
    overflow: hidden;
    background: #000;
    transition: aspect-ratio 0.45s ease;
}
/* No barcode match: collapse the (now camera-less, black) viewport to a 2:1
   strip. It stays on screen because that is where the rescue reticle and the
   "Scan another" CTA live. */
.barcode-scan-camera-wrap.is-compact { aspect-ratio: 2 / 1; }
/* Real result: the viewport goes away entirely and a plain .btn replaces it —
   a black rectangle of stopped camera earns none of that space when there are
   products to show. display:none, NOT v-if: the html5-qrcode instance keeps a
   reference to the reader element it was constructed with, so the node has to
   survive for "Scan another" to restart into something visible. */
.barcode-scan-camera-wrap.is-collapsed { display: none; }

/* Idle state — black box with a centered Start-camera CTA, so the viewport
   is always present (no jump-in when the camera spins up). */
.barcode-scan-idle-overlay {
    position: absolute;
    inset: 0;
    z-index: 4;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 14px;
    color: rgba(255, 255, 255, 0.85);
}

/* Scanning laser — a glowing white line sweeping the viewport so the user
   can see we're actively reading. ::before is a soft trailing band (the
   "slight background effect"). pointer-events:none keeps the feed tappable. */
.barcode-scan-laser {
    /* Confined to ~the central 65% square that the library actually decodes
       (see _qrboxFn) so the sweep honestly shows where to aim — a barcode
       outside this band is not read. */
    position: absolute;
    left: 17%;
    right: 17%;
    height: 2px;
    z-index: 3;
    pointer-events: none;
    background: #fff;
    border-radius: 2px;
    box-shadow: 0 0 8px 2px rgba(255, 255, 255, 0.8),
                0 0 26px 7px rgba(255, 255, 255, 0.35);
    animation: barcode-laser 2.2s ease-in-out infinite alternate;
}
.barcode-scan-laser::before {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    top: -30px;
    height: 60px;
    background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.14), transparent);
}
@keyframes barcode-laser {
    from { top: 18%; }
    to   { top: 82%; }
}
@media (prefers-reduced-motion: reduce) {
    .barcode-scan-laser { animation: none; top: 50%; }
}
.barcode-scan-reader {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
}
.barcode-scan-camera-wrap video {
    width: 100% !important;
    height: 100% !important;
    object-fit: cover !important;
    display: block;
}

/* Top-right control cluster (torch + camera-switch). Buttons positioned
   absolutely so they float over the camera feed; only the icon-button
   children should react to clicks (.cluster itself is non-interactive). */
.barcode-scan-cam-overlay-controls {
    position: absolute;
    top: 10px;
    right: 10px;
    display: flex;
    gap: 8px;
    z-index: 5;
}
.barcode-scan-icon-btn {
    width: 38px; height: 38px;
    border-radius: 50%;
    border: none;
    background: rgba(0, 0, 0, 0.55);
    color: #fff;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    transition: background 0.15s, transform 0.1s;
    -webkit-backdrop-filter: blur(2px);
    backdrop-filter: blur(2px);
}
.barcode-scan-icon-btn:hover  { background: rgba(0, 0, 0, 0.75); }
.barcode-scan-icon-btn:active { transform: scale(0.92); }
.barcode-scan-icon-btn.active {
    background: var(--pink-highlight);
}

/* Full-viewport state overlay: lookup spinner OR "Scan another" CTA. */
.barcode-scan-state-overlay {
    position: absolute; inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 12px;
    background: rgba(0, 0, 0, 0.55);
    color: #fff;
    text-align: center;
    z-index: 4;
    -webkit-backdrop-filter: blur(2px);
    backdrop-filter: blur(2px);
}
.barcode-scan-state-overlay p { margin: 0; font-size: var(--fs-md); }

.barcode-scan-spinner {
    width: 46px; height: 46px;
    border: 4px solid rgba(255, 255, 255, 0.25);
    border-top-color: #fff;
    border-radius: 50%;
    animation: barcode-scan-spin 0.8s linear infinite;
}
@keyframes barcode-scan-spin { to { transform: rotate(360deg); } }

/* ── Lookup-in-flight reticle (.scan-reticle) ────────────────────────────
   SHARED by the two slow lookups on this page — do not fork it:
     1. the barcode scanner's web rescue (~12s), in place of "Scan another";
     2. the paste-a-URL lookup (.search-url-result), which fetches the pasted
        page server-side and is likewise seconds long.
   A spinner would say "loading"; this says WHAT we are doing — a product held
   in a scanner frame with a beam passing over it — which is the difference
   between a wait the user tolerates and one where they give up and retry
   something we are already working on.
   Deliberately reuses the .barcode-scan-laser vocabulary (white glow + soft
   trailing band) so the page has ONE scanning language, just scoped to a
   square instead of the camera viewport.
   Renamed off .barcode-scan-identify on 2026-07-30 when the URL lookup
   adopted it: the pair is no longer barcode-specific, and a name that lies
   invites the next scanner-only tweak to break the other caller silently.
   Geometry is per-context (below) because the two backdrops differ; only the
   frame, the glyph scale and the beam are shared. */
.scan-reticle {
    position: relative;
    aspect-ratio: 1 / 1;
    border-radius: 14px;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    /* A visible frame, not a hairline: the backdrop is dark in BOTH callers
       (the frozen camera viewport / the tile's own fill), so a 6%-white panel
       is invisible against it and the square stops reading as a container. */
    background: rgba(255, 255, 255, 0.10);
    border: 2px solid rgba(255, 255, 255, 0.45);
}
/* Scanner context — the reticle is a column flex item inside the state
   overlay, over the (black) camera viewport, so it needs no fill of its own.
   MUST stay square on short viewports. As a column flex item its height is
   the main axis, so the default flex-shrink squashed it to 108x60 on a
   360px phone (measured) — the strip is only ~114px tall there once the
   viewport collapses to 2:1. flex:0 0 auto stops the squash and the
   height cap scales it down instead, with aspect-ratio holding the square. */
.barcode-scan-state-overlay .scan-reticle {
    flex: 0 0 auto;
    height: min(108px, 52%);
    width: auto;
}
/* URL-lookup context — here the backdrop is a LIGHT card (--bg-light), where
   a white beam on a 10%-white panel is invisible. So the tile brings its own
   dark fill: --text-color is the darkest neutral in the palette and is what
   the beam's whole light-over-dark metaphor needs to survive off-camera. Only
   the 96px square goes dark, not the notice.
   Explicit width (not width:auto + aspect-ratio) because this one is a plain
   block, not a flex item — auto would stretch it to the card. margin:auto
   centres it, since .search-url-result centres its children with text-align,
   which does nothing to a block-level flex container. 96 not the scanner's
   108: that was sized against a 420px camera viewport, this sits above one
   line of --fs-md copy in a 640px card. */
.search-url-result .scan-reticle {
    width: 96px;
    height: 96px;
    margin: 0 auto 12px;
    background: var(--text-color);
}
/* Scale the glyph WITH the square rather than trusting the size prop: the
   frame shrinks on short viewports, and a fixed 36px filled 61% of the 59px
   box there. 34% lands back on ~37px at full size. */
.scan-reticle svg {
    width: 34%;
    height: auto;
}
/* Sweeps OVER the product glyph (z-index above it), so the beam reads as
   passing across the product rather than sitting behind it.
   The glow is deliberately TIGHTER than .barcode-scan-laser's: that one is
   sized for the 420px viewport, and at those values (8px/26px spread) inside a
   108px box the halo fills the whole square and the beam looks like a smudge. */
.scan-reticle-beam {
    position: absolute;
    left: 0;
    right: 0;
    height: 2px;
    z-index: 2;
    background: #fff;
    box-shadow: 0 0 6px 1px rgba(255, 255, 255, 0.7);
    animation: scan-reticle-beam 1.4s ease-in-out infinite alternate;
}
.scan-reticle-beam::before {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    top: -11px;
    height: 22px;
    background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.14), transparent);
}
/* Stops short of the frame at both ends so the beam never sits on the border
   and read as a thick edge. */
@keyframes scan-reticle-beam {
    from { top: 14%; }
    to   { top: 86%; }
}
@media (prefers-reduced-motion: reduce) {
    .scan-reticle-beam { animation: none; top: 50%; }
}

.barcode-scan-error {
    text-align: center;
    margin: 8px 0;
    padding: 10px 14px;
    background: var(--pill-red-bg);
    color: var(--pill-red-text);
    border-radius: 6px;
    font-size: var(--fs-base);
}

/* ── Post-scan results section (found / empty) ──────────────────────────── */
.barcode-scan-results { margin-top: 8px; }
/* ── Result footer: both CTAs ────────────────────────────────────────────
   DEFAULT = the SHEET (≤1200px, which is every phone and tablet — .modal-dialog
   only exists from 1201px, see core.css). Stacked, "Show all" on top.
   column-reverse rather than swapping the DOM: the markup keeps the standard
   footer order (secondary first, primary rightmost) so the desktop dialog needs
   no override, and the primary stays in the exact spot it occupied when it was
   the only button — adding the second one must not move it.
   `flex: 0 0 auto` is REQUIRED, not tidiness: .modal-footer-stretch sets
   `flex: 1` on the children, whose flex-basis:0 applies to the MAIN axis — in a
   column that is height, so the buttons would collapse toward zero height and
   grow only into a footer that has no free space to give. */
.barcode-scan-footer {
    flex-direction: column-reverse;
}
.barcode-scan-footer > button {
    flex: 0 0 auto;
    width: 100%;
}
/* The dialog tier — the modal's own sheet→dialog threshold, the same 1201px
   .modal-footer-close-desktop uses. Side by side, secondary on the left, which
   is just the default .modal-footer row. */
@media (min-width: 1201px) {
    .barcode-scan-footer {
        flex-direction: row;
    }
    .barcode-scan-footer > button {
        flex: 1;
        width: auto;
    }
}
/* Success banner shown where the viewport was on a match. Carries the count
   AND the barcode, so it is the only line here — the muted count/pill sentence
   that used to follow it said the same thing twice.
   Plain block, NOT flex: it was a flex row only to sit an icon beside the text,
   and the icon is gone. As a flex row the trailing barcode pill became its own
   flex item, so it stayed vertically centred beside a two-line sentence instead
   of flowing with it; inline flow wraps the pill like the words around it. */
.barcode-scan-success {
    text-align: center;
    margin: 0 0 12px;
    padding: 10px 14px;
    background: var(--pill-green-bg);
    color: var(--pill-green-text);
    border-radius: 8px;
    font-size: var(--fs-md);
    font-weight: 600;
}
.barcode-scan-results-head {
    margin: 0 0 10px;
    text-align: center;
    font-size: var(--fs-md);
}
.barcode-scan-code-pill {
    display: inline-block;
    padding: 2px 8px;
    background: var(--bg-light);
    border-radius: 10px;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: max(0.85em, var(--fs-3xs));
}
/* Full-width row list — reuses .modal-product-preview / .variants-row
   styling from components.css so the layout matches the Add-to-List /
   Variants modal exactly (thumbnail left, shop favicon + domain + title +
   price stacked right). Scrolls when more rows than fit; whole row is the
   click target. */
.barcode-scan-list {
    margin-bottom: 14px;
    max-height: 360px;
    overflow-y: auto;
}
.barcode-scan-row {
    cursor: pointer;
    border-radius: 8px;
    padding: 12px;
    margin-bottom: 8px;
    border-bottom: none;
    transition: background 0.15s;
}
.barcode-scan-row:hover { background: rgba(182, 3, 120, 0.04); }
.barcode-scan-row:last-child { margin-bottom: 0; }
.barcode-scan-tile-img-empty {
    background: repeating-linear-gradient(45deg, var(--surface-track) 0 8px, #fafafa 8px 16px);
}

.barcode-scan-empty-msg {
    text-align: center;
    margin: 0 0 14px;
    padding: 12px 14px;
    background: var(--bg-light);
    border-radius: 8px;
    font-size: var(--fs-md);
}

/* The "or enter manually" separator — thin horizontal line with text */
.barcode-scan-separator {
    display: flex;
    align-items: center;
    text-align: center;
    margin: 20px 0 14px;
    color: var(--text-light);
    font-size: var(--fs-base);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.barcode-scan-separator::before,
.barcode-scan-separator::after {
    content: '';
    flex: 1;
    height: 1px;
    background: var(--border-color);
}
.barcode-scan-separator span { padding: 0 12px; }

/* Manual text fallback is the shared <search-field submit> (pill + magnifier,
   clear-×) — no wrapper or bespoke CSS needed; it fills the body width on its own. */

/* Motion opt-out for the scanner (moved with it from pages/search.css). */
@media (prefers-reduced-motion: reduce) {
    .barcode-scan-camera-wrap,
    .barcode-scan-icon-btn,
    .barcode-scan-row {
        transition: none;
        animation: none;
    }
    /* Progress feedback, not decoration: its transitions go; its spin stays. */
    .barcode-scan-spinner { transition: none; }
}

/* ============================================================================
   Stories — StoriesBar.js (the ring row, the profile ring) and StoryViewer.js
   ============================================================================
   The viewer is a modal shell (.modal-overlay.story-overlay + .story-viewer)
   so modal-keys.js gives it Escape, the focus trap and the scroll lock. Full
   screen on a phone; a phone-shaped card over the dim from 601px. Product
   images keep the platform's 250×250 container: a 1-up slide never draws one
   larger than that, 2-up and 4-up draw it smaller. */
.stories-bar { margin: 0 0 16px; }
.stories-bar-list {
    display: flex;
    gap: 14px;
    margin: 0;
    padding: 4px 2px;
    list-style: none;
    overflow-x: auto;
    scrollbar-width: none;
}
.stories-bar-list::-webkit-scrollbar { display: none; }
/* js/lib/tab-scroll.js gives the row mouse drag + wheel and marks the edges
   that have rings behind them; the fade says the row moves. */
.stories-bar-list.is-scroll-start,
.stories-bar-list.is-scroll-end { cursor: grab; }
.stories-bar-list.is-dragging { cursor: grabbing; user-select: none; }
.stories-bar-list.is-scroll-end {
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 44px), transparent 100%);
            mask-image: linear-gradient(to right, #000 calc(100% - 44px), transparent 100%);
}
.stories-bar-list.is-scroll-start {
    -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 44px), transparent 100%);
            mask-image: linear-gradient(to left, #000 calc(100% - 44px), transparent 100%);
}
.stories-bar-list.is-scroll-start.is-scroll-end {
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 44px, #000 calc(100% - 44px), transparent 100%);
            mask-image: linear-gradient(to right, transparent 0, #000 44px, #000 calc(100% - 44px), transparent 100%);
}
.stories-bar-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    width: 76px;
    padding: 0;
    border: 0;
    background: none;
    color: var(--text-color);
    font: inherit;
    cursor: pointer;
}
.stories-bar-name {
    max-width: 100%;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: var(--fs-xs);
}
/* The ring is an icon frame around a picture, so px. Lit = something the
   viewer has not seen; seen = the neutral border colour. */
.story-ring {
    display: block;
    padding: 3px;
    border-radius: 50%;
    background: var(--pink-highlight);
}
.story-ring--seen { background: var(--border-color); }
.stories-bar .story-ring { width: 70px; height: 70px; box-sizing: border-box; }
.story-ring--btn { flex-shrink: 0; border: 0; cursor: pointer; }
/* A span inside a <button> stays inline and drops its 56px box; the ring is
   the colour, so the frame becomes the white gap the bar's rings have. */
.story-ring--btn .entity-header-avatar { display: block; border-color: var(--surface); }
.stories-bar-item:focus-visible { outline: none; }
.stories-bar-item:focus-visible .story-ring,
.story-ring--btn:focus-visible { outline: 2px solid var(--blue-highlight); outline-offset: 2px; }
.story-ring-avatar {
    display: block;
    width: 100%;
    height: 100%;
    box-sizing: border-box;
    border: 2px solid var(--surface);
    border-radius: 50%;
    background: var(--surface);
    object-fit: cover;
}

/* ── Viewer ── */
.story-viewer {
    position: fixed;
    inset: 0;
    /* The stacked rung, with its overlay's .modal-stacked: opened from the
       story editor modal at --z-modal, the card and the white arrows on the
       dim would otherwise land under or on that modal. */
    z-index: var(--z-modal-stacked);
    display: flex;
    flex-direction: column;
    outline: none;
}
/* The editor's slide strip (.story-mini) wears the viewer's own background. */
.story-viewer,
.story-mini {
    /* --story-tint-* are set per slide by StoryViewer (tintVars) from the
       products' image colour; unset, the gradient is plain surface. */
    background: linear-gradient(170deg,
        var(--story-tint-1, var(--surface)) 0%,
        var(--story-tint-2, var(--surface)) 50%,
        var(--story-tint-3, var(--surface)) 100%);
    color: var(--text-color);
}
/* A slide's background photo (--story-bg). Surface washes over the top and
   bottom bands keep the head and "See more" readable, and each product (or a
   text slide's text) sits on its own surface card, since text cannot be held
   to AA against an arbitrary photo. */
.story-viewer.story-viewer--bg,
.story-mini.story-viewer--bg {
    background:
        linear-gradient(to bottom,
            color-mix(in srgb, var(--surface) 85%, transparent) 0,
            transparent 120px,
            transparent calc(100% - 140px),
            color-mix(in srgb, var(--surface) 85%, transparent) 100%),
        var(--story-bg) center / cover no-repeat,
        var(--surface);
}
.story-viewer--bg .story-product,
.story-viewer--bg .story-cover {
    padding: 8px;
    border-radius: var(--radius-lg);
    background: color-mix(in srgb, var(--surface) 92%, transparent);
}
/* One slide drawn at the viewer's phone size (360×640) and scaled to half,
   so the editor previews the real layout rather than an approximation. */
.story-mini {
    position: relative;
    display: block;
    width: 180px;
    height: 320px;
    padding: 0;
    overflow: hidden;
    border: 2px solid var(--border-color);
    border-radius: var(--radius-lg);
    cursor: pointer;
}
.story-mini--active { border-color: var(--pink-highlight); }
.story-mini-stage {
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    flex-direction: column;
    width: 360px;
    height: 640px;
    transform: scale(0.5);
    transform-origin: 0 0;
    pointer-events: none;
    text-align: center;
}
.story-mini-stage .story-stage { overflow: hidden; }
/* A page modal opened from a slide (Save, sign-up, leaving) sits on top. */
.story-viewer--covered { z-index: calc(var(--z-modal-backdrop) - 10); }
/* The cube's far face: the neighbouring story's cover, mid-turn only. */
.story-viewer--face { pointer-events: none; backface-visibility: hidden; }
@media (min-width: 601px) {
    /* The story floats as a card on desktop; a near-opaque backdrop in the
       nav band's dark keeps the page from competing with it. */
    .story-overlay { background-color: color-mix(in srgb, var(--text-color) 90%, transparent); }
    .story-viewer {
        margin: auto;
        width: min(420px, calc(100vw - 32px));
        height: min(820px, calc(100dvh - 48px));
        border-radius: var(--radius-lg);
        box-shadow: var(--shadow-lg);
    }
}
.story-progress { display: flex; gap: 3px; padding: 10px 12px 0; }
.story-progress-seg {
    flex: 1;
    height: 2px;
    border-radius: 1px;
    background: var(--border-color);
    overflow: hidden;
}
.story-progress-fill {
    display: block;
    height: 100%;
    background: var(--pink-highlight);
    transform-origin: left center;
}
.story-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 6px 6px 2px 12px;
}
.story-owner {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    color: var(--text-color);
    font-size: var(--fs-lg);
    font-weight: 600;
    text-decoration: none;
}
.story-owner-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.story-owner-avatar {
    width: 46px;
    height: 46px;
    flex-shrink: 0;
    border-radius: 50%;
    object-fit: cover;
}
/* Shop "avatars" are logos: never crop them, and inset them so a wide
   logo's corners stay inside the circle. */
.story-owner-avatar--shop {
    box-sizing: border-box;
    padding: 5px;
    object-fit: contain;
    background: var(--surface);
    border: 1px solid var(--border-color);
}
.story-cover-avatar.story-owner-avatar--shop { padding: 12px; object-fit: contain; }
.story-ring-avatar.story-owner-avatar--shop { padding: 7px; object-fit: contain; }
.story-head-actions { display: flex; gap: 2px; flex-shrink: 0; }
.story-icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: none;
    color: var(--text-color);
    cursor: pointer;
}
@media (hover: hover) {
    .story-icon-btn:hover { background: var(--bg-light); }
}
.story-stage {
    position: relative;
    flex: 1;
    min-height: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 12px 16px;
    overflow-y: auto;
    /* none, not pan-y: with pan-y the browser takes a vertical finger swipe
       as a scroll and cancels the pointer, so swipe down (close) and swipe
       up (open the list) never reached the viewer on a phone. */
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
}
.story-empty { color: var(--text-light); text-align: center; }
.story-cover {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    text-align: center;
}
/* Follow / Share (the cover's slot) stands apart from the avatar under it. */
.story-cover > .btn,
.story-cover > .btn-primary { margin-bottom: 16px; }
.story-cover-avatar { width: 112px; height: 112px; border-radius: 50%; object-fit: cover; }
.story-cover-owner { margin: 0; color: var(--text-light); font-size: var(--fs-md); }
.story-cover-title { margin: 0; font-size: var(--fs-2xl); font-weight: 700; line-height: 1.2; }
.story-cover-count { margin: 0 0 8px; color: var(--text-light); font-size: var(--fs-base); }
/* --story-cover-cols is set by StoryViewer (coverCols); the tracks shrink
   below 80px rather than wrap a fifth tile onto a narrow phone, and
   --story-cover-thumb (fitSlide) shrinks them on a short one. */
.story-cover-thumbs {
    display: grid;
    grid-template-columns: repeat(var(--story-cover-cols, 4), minmax(0, var(--story-cover-thumb, 80px)));
    justify-content: center;
    gap: 8px;
    width: 100%;
}
.story-cover-thumb {
    width: 100%;
    height: auto;
    aspect-ratio: 1;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    background: var(--surface);
    object-fit: contain;
}
.story-cover-thumbs .share-thumb-more { font-size: var(--fs-lg); }
.story-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 12px;
    width: 100%;
    align-content: center;
}
.story-grid--1 { grid-template-columns: minmax(0, 250px); justify-content: center; }
/* Stacked. On a short screen StoryViewer.fitSlide narrows the images
   (--story-img) until both fit; the column keeps its width for the text. */
.story-grid--2 { grid-template-columns: minmax(0, 220px); justify-content: center; }
/* --fs-lg is the product card's own context, so the shared .product-price
   (em-sized) renders at card size. Centred like the card. */
.story-product { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; font-size: var(--fs-lg); text-align: center; }
.story-product-img {
    display: block;
    width: min(100%, var(--story-img, 100%));
    aspect-ratio: 1;
    overflow: hidden;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    background: var(--surface);
}
.story-product-img img { display: block; width: 100%; height: 100%; object-fit: contain; }
.story-product-img { animation: story-img-in 0.6s ease-out both; }
/* Single product: a light trails round the image's border. The frame is the
   gradient's border-box layer showing through a transparent border; only the
   angle moves, and @property is what lets an angle inside a gradient tween. */
@property --story-trail {
    syntax: '<angle>';
    inherits: false;
    initial-value: 0deg;
}
.story-grid--1 .story-product-img {
    border: 4px solid transparent;
    background:
        linear-gradient(var(--surface), var(--surface)) padding-box,
        conic-gradient(from var(--story-trail),
            var(--surface) 0deg 150deg,
            var(--blue-highlight-pale) 250deg,
            var(--blue-highlight-pale-hover) 345deg,
            var(--surface) 360deg) border-box;
    animation: story-img-in 0.6s ease-out both, story-trail 4s linear infinite;
}
@keyframes story-trail { to { --story-trail: 360deg; } }
@keyframes story-img-in {
    from { opacity: 0; transform: translateY(-12px); }
}
.story-product-title {
    display: -webkit-box;
    margin: 0;
    overflow: hidden;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    font-size: var(--fs-md);
}
.story-grid--1 .story-product-title { font-size: var(--fs-lg); }
.story-product .product-price-pair { font-size: var(--fs-xl); }
/* Four to a slide: a smaller step up, or the text leaves the images no room. */
.story-grid--4 .story-product-title { font-size: var(--fs-base); }
.story-grid--4 .product-price-pair { font-size: var(--fs-lg); }
.story-product .product-brand { margin: 0; }
.story-product-shop { display: flex; align-items: center; gap: 4px; margin: 0; color: var(--text-light); font-size: var(--fs-xs); }
.story-note {
    grid-column: 1 / -1;
    margin: 4px 0 0;
    padding: 12px 14px;
    border-radius: var(--radius-lg);
    background: var(--pink-highlight-pale);
    font-size: var(--fs-md);
}
.story-note p { margin: 0; }
.story-foot { display: flex; justify-content: center; gap: 8px; padding: 4px 16px calc(24px + env(safe-area-inset-bottom)); }
.story-more {
    display: flex;
    flex-direction: column;
    align-items: center;
    color: var(--text-color);
    text-decoration: none;
}
/* Wins back the generic a:hover underline + colour (core.css, 0,1,1). */
.story-owner:hover,
.story-more:hover {
    color: var(--text-color);
    text-decoration: none;
}
.story-more-arrow { animation: story-more-nudge 1.6s ease-in-out infinite; }
@keyframes story-more-nudge {
    0%, 100% { transform: translateY(0); }
    50% { transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
    .story-more-arrow,
    .story-product-img,
    .story-grid--1 .story-product-img { animation: none; }
}
/* A common Android / iPhone 8 height: four titles at two lines each crowd
   the images down to thumbnails. */
@media (max-height: 740px) {
    .story-grid--4 .story-product-title { -webkit-line-clamp: 1; }
    .story-cover-avatar { width: 88px; height: 88px; }
    .story-cover-avatar.story-owner-avatar--shop { padding: 10px; }
}
/* A short phone (SE 1st gen, landscape): fitSlide would otherwise squeeze
   the images below a readable size to make room for two-line titles. */
@media (max-height: 600px) {
    .story-cover-avatar { width: 72px; height: 72px; }
    .story-cover-avatar.story-owner-avatar--shop { padding: 8px; }
    .story-grid--2 .story-product-title { -webkit-line-clamp: 1; }
    .story-grid--2 .product-price-pair { font-size: var(--fs-lg); }
    .story-cover-title { font-size: var(--fs-xl); }
    .story-stage { padding-block: 6px; }
    .story-cover { gap: 4px; }
    .story-cover > .btn,
    .story-cover > .btn-primary { margin-bottom: 8px; }
    .story-foot { padding-bottom: calc(8px + env(safe-area-inset-bottom)); }
}
/* The arrows are the keyboard / screen-reader route; a finger taps the
   stage. Phone: invisible and click-through over the stage's bottom corners
   until one takes keyboard focus. */
.story-nav {
    position: absolute;
    inset: auto 0 calc(8px + env(safe-area-inset-bottom));
    display: flex;
    justify-content: space-between;
    padding: 0 8px;
    pointer-events: none;
    opacity: 0;
}
.story-nav:focus-within { opacity: 1; }
.story-nav .story-icon-btn { background: var(--surface); }
.story-nav .story-icon-btn:focus-visible { outline: 2px solid var(--blue-highlight); outline-offset: 2px; }
/* Desktop: the arrows sit on the backdrop either side of the card, centred
   on it. Still inside .story-viewer, so they stay in the dialog's focus trap
   and turn with the cube. Needs the viewer unclipped (no overflow: hidden
   above); 64px a side fits from 601px up. */
@media (min-width: 601px) {
    .story-nav {
        inset: 50% -64px auto;
        transform: translateY(-50%);
        padding: 0;
        opacity: 1;
    }
    .story-nav .story-icon-btn { width: 48px; height: 48px; background: none; color: var(--surface); pointer-events: auto; }
    @media (hover: hover) {
        .story-nav .story-icon-btn:hover { background: color-mix(in srgb, var(--surface) 15%, transparent); }
    }
    .story-nav .story-icon-btn:focus-visible { outline-color: var(--surface); }
}
