
/* ── Inclusive Sans, self-hosted ──────────────────────────────────────────────────────────────
   SELF-HOSTED AND NOT LINKED, because the storefront's CSP is `default-src 'self'`: a
   `fonts.googleapis.com` stylesheet and the `fonts.gstatic.com` woff2 it points at are both
   blocked outright, so a linked webfont would silently fall back to whatever the device happened
   to have. That is exactly what happened — the face this shop was matched against rendered as
   Optima on macOS, which is why nothing looked right at any size.

   Variable weight 300-700, so ONE file covers every weight the shop uses. Vietnamese is dropped
   (a UK vape retailer); latin and latin-ext remain, and `unicode-range` means a browser fetches
   only the subset a page actually needs.

   SIL Open Font License 1.1 — Copyright 2022 The Inclusive Sans Project Authors. The licence
   requires its notice to travel with the font: see OFL.txt beside the woff2 files. */
@font-face {
  font-family: 'Inclusive Sans';
  font-style: italic;
  font-weight: 300 700;
  font-display: swap;
  src: url('/assets/fonts/inclusive-sans/inclusive-sans-latin-ext-italic.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Inclusive Sans';
  font-style: italic;
  font-weight: 300 700;
  font-display: swap;
  src: url('/assets/fonts/inclusive-sans/inclusive-sans-latin-italic.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Inclusive Sans';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url('/assets/fonts/inclusive-sans/inclusive-sans-latin-ext-normal.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Inclusive Sans';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url('/assets/fonts/inclusive-sans/inclusive-sans-latin-normal.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Storefront theme (customer-facing). Neutral, clean, responsive. Light + dark aware. */
:root {
  --sf-ink: #1a1f2b; --sf-muted: #5b6472; --sf-faint: #6a717e;
  --sf-bg: #ffffff; --sf-surface: #f7f8fa; --sf-border: #8c9198;
  /* ⚠️ --sf-faint WAS #8a93a3 AND --sf-border WAS #e5e8ee UNTIL 0104, and both failed WCAG on the
     stock shop — 3.1:1 and 1.2:1. They are the platform defaults, so they were what every store that
     never touched those two controls rendered, which is all three trading shops.
       · faint draws .sf-footer-legal (12px), .sf-pd-vat (14px), .sf-paynote (12px), .sf-sum-note
         (11px) — the VAT line and the footer legal statement, normal-size text, bar 4.5:1.
       · border is the ONLY visual boundary of every field a customer types into (.sf-search input,
         .sf-field input, .sf-coupon-form input, .sf-cart-qtyform input, .sf-sort select). Those are
         filled --sf-surface on a --sf-bg page, 1.05:1, so with an invisible border nothing says
         "type here" at all. WCAG 1.4.11 wants 3:1 and #e5e8ee gave 1.2:1.
     Both new values were SOLVED for the lightest colour that clears the bar, not picked, so fine
     print stays quieter than --sf-muted and a hairline stays a hairline. Mirrored in
     ThemeTokens::CATALOGUE — these two lists ARE the same list and ThemePresetContrastTest measures
     the PHP half on every build. The cost of the border change is a visible frame on .sf-card, which
     is one token doing two jobs; splitting a --sf-field-border out is owed. */
  /* --sf-brand is the platform default; theme.js overrides it per store from data-sf-brand.
     --sf-accent derives from it (a light brand tint) so ONE colour recolours the storefront. */
  --sf-brand: #4f46e5; --sf-brand-ink: #ffffff;
  --sf-accent: color-mix(in srgb, var(--sf-brand) 10%, #ffffff);
  --sf-warn-bg: #fdeddb; --sf-warn-ink: #a15c11;
  --sf-radius: 12px; --sf-shadow: 0 1px 3px rgba(20,24,40,.08);
  /* Design-panel tokens (0067). Every one of these is overridable per store via /theme.css; the
     values here are the platform defaults a store inherits until it sets its own. */
  --sf-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --sf-font-size: 16px;
  --sf-heading-weight: 800;
  --sf-heading-tracking: -0.02em;
  --sf-container: 1120px;
  --sf-header-h: 62px;
  /* ── WHO OWNS THE HEADER'S HEIGHT (§7 Q1, answered (b)) ────────────────────────────────────────
     `--sf-header-h` is THE BAR'S OWN height and stays that way — it is the Theme → Layout slider a
     merchant already uses on nine presets, and taking it away (option (a)) to let chrome compute a
     total would remove a working control to enable one that does not exist yet.

     `--sf-header-total` is the WHOLE header: bar plus a second row plus an announcement strip. It
     defaults to the bar's height, so today the two are the same number and nothing moves. It exists
     so that anything positioning itself BELOW the header reads a value chrome can own once bands
     carry their own heights, without that ever reaching back into the slider.

     ⚠️ THE SHRINK KEYFRAMES DELIBERATELY DO NOT USE IT. They animate `.sf-header-in`, which IS the
     bar, so the bar's own height is the correct input — the plan grouped them with `.sf-filters`,
     and reading the rule shows they belong on the other side of the split. */
  --sf-header-total: var(--sf-header-h);
}
/* ── THE DARK PALETTE, MEASURED (L6) ───────────────────────────────────────────────────────────
   `ThemePresetContrastTest` gates all ten shipped presets on ten pairs. It has never run against
   the dark swap, and the dark swap failed FOUR of them:

     faint     on surface  4.37  (bar 4.5)
     border    on bg       1.41  (bar 3.0)
     border    on surface  1.27  (bar 3.0)     <- a hairline nobody could see
     brand_ink on brand    3.62  (bar 4.5)     <- the buy button

   Every value below is the SMALLEST change that clears its bar, so the palette moves as little as
   it can while meeting the gate the platform already applies to every merchant preset:

     --sf-faint  #7b8494 -> #8b94a5   surface 4.37 -> 5.39
     --sf-border #2a3142 -> #646f88   surface 1.27 -> 3.27, bg 1.41 -> 3.63

   ⚠️ `--sf-brand-ink` was ABSENT from this block, so dark mode inherited the light `#ffffff` onto
   a lighter dark-mode brand — 3.62:1. The `after_dark` preset already answers this exact question
   the same way (`ThemeTokens.php:517`: brand #8b83ff, brand_ink #0d0f18): on a pale brand the label
   goes dark, not white. #0f1729 on #7c74ff is 4.94:1. */
@media (prefers-color-scheme: dark) {
  :root {
    --sf-ink: #eef1f6; --sf-muted: #a4adbd; --sf-faint: #8b94a5;
    --sf-bg: #12151c; --sf-surface: #1a1f2b; --sf-border: #646f88;
    --sf-brand: #7c74ff; --sf-brand-ink: #0f1729; --sf-accent: color-mix(in srgb, var(--sf-brand) 24%, #12151c); --sf-warn-bg: #3a2c17; --sf-warn-ink: #f0c48a;
  }
}
* { box-sizing: border-box; }
body { margin: 0; font-family: var(--sf-font); font-size: var(--sf-font-size); color: var(--sf-ink); background: var(--sf-bg); line-height: var(--sf-line-height, 1.5); }
/* LINKS INSIDE A BLOCK (0101). Both values are exactly what this rule has always carried, moved
   behind a fallback so a block can override them — the same arrangement every other per-block
   control uses, and provably a no-op with nothing set. Deliberately NOT a new `.sf-block a` rule:
   that would tie with `.sf-page-body a` below on specificity and source order would silently decide
   which of the two won, on shops nobody had touched. `--sf-sec-link-*` is only ever defined inside
   a `.sf-sec-N` scope, so every link elsewhere on the shop resolves the fallback and is unchanged. */
a { color: var(--sf-sec-link-colour, inherit); text-decoration: var(--sf-sec-link-deco, none); }
.sf-wrap { max-width: var(--sf-container); margin: 0 auto; padding: 0 20px; }

.sf-header { border-bottom: var(--sf-band-bd-w, 1px) solid var(--sf-band-bd-c, var(--sf-border)); background: var(--sf-band-bg, var(--sf-bg)); background-image: var(--sf-band-img, none); background-size: var(--sf-band-img-size, cover); background-position: var(--sf-band-img-pos, center); background-repeat: var(--sf-band-img-repeat, no-repeat); color: var(--sf-band-ink, var(--sf-band-ink-auto, inherit)); position: sticky; top: 0; z-index: 10; }
.sf-header-in { display: flex; align-items: center; justify-content: space-between; height: var(--sf-header-h); }
.sf-brand { font-size: 19px; font-weight: var(--sf-heading-weight); letter-spacing: var(--sf-heading-tracking); }
.sf-nav { display: flex; gap: 22px; align-items: center; font-size: 15px; }
/* ⚠️ THE HOVER MUST NOT ASSUME A LIGHT BAND — IT DID, AND THE LINK VANISHED.
 *
 * This was written when the header was always the page background. A header may now be a PAINTED
 * band, and a shop that paints it with its own brand colour got `color: var(--sf-brand)` on hover
 * over a `--sf-brand` background: contrast 1.00, the word disappearing under the cursor. Live on
 * vapeandgo.co.uk, whose header band is #035c56. Reported by the owner, who could see the effect
 * long before any of us could name it.
 *
 * The fix is the fallback chain `.sf-header` itself already uses, so hover lands on the band's own
 * ink wherever one is set, and on the brand only where there is no band ink — the classic header,
 * whose behaviour is unchanged. The underline carries the state in that first case and, being
 * colour-independent, is what makes this rule safe on a band whose ink we cannot predict.
 *
 * `.sf-nav-toggle` (the "Home ⌄" / "Brands ⌄" openers) carried the identical bug and is fixed here
 * with it rather than left as a second, drifting answer. `.sf-cart-link:hover` further down keeps
 * `--sf-brand`: it is painted on its own light pill, measured at 7.87 contrast, and is fine. */
.sf-nav a:hover,
.sf-nav-toggle:hover {
  color: var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-brand)));
  text-decoration: underline;
  text-underline-offset: 4px;
  text-decoration-thickness: 2px;
}
/* ⚠️ EXCEPT INSIDE A PANEL, WHICH IS NOT THE BAND — and the rule above created this bug.
 *
 * The band ink is chosen for the header's painted strip; on this shop that is #ffffff. A dropdown
 * panel paints its OWN ground (`--sf-bg`, white here), so sending its links to the band's ink on
 * hover put white on white: contrast 1.00, the column titles disappearing under the cursor. The
 * owner reported it as "nav link name in sub menu hiding".
 *
 * ⚠️ ONLY THE COLUMN TITLES VANISHED, which is why it survived a look at the menu. The links inside
 * each column are also matched by `.sf-mega .sf-nav-panel .sf-nav-link:hover` (four parts, ink), so
 * they were fine. The level-2 titles sit in the mega itself rather than in a nested panel, so
 * nothing outranked the band rule for them. Half the panel worked and half did not.
 *
 * This is the same lesson as the panel's resting ink two fixes ago, arriving from the other side:
 * a surface that paints its own ground owns its ink in EVERY state, hover included. Three parts
 * plus an element beats the two-plus-element rule above, so it applies at both levels. */
.sf-nav .sf-nav-panel a:hover { color: var(--sf-ink); }
.sf-cart-n { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--sf-brand); color: var(--sf-brand-ink); font-size: 12px; font-weight: 700; }

.sf-main { padding: 32px 20px 64px; min-height: 60vh; }
/* HEADINGS, and the block-level override that has to inherit by default.
   `--sf-sec-head-*` is set only under a block that asked for it, so outside one — a listing page
   title, a checkout heading — the property is unset and every rule keeps the value it always had.
   Custom properties inherit, which is the whole mechanism: the block wrapper carries them and the
   heading anywhere inside it reads them, with no selector needing to know the block exists.

   THREE OF THE FIVE FALL BACK TO `inherit`, AND THAT IS NOT LAZINESS (0098). `font-family`,
   `text-transform` and `line-height` are all INHERITED properties, so `inherit` is by definition
   the value the element already had — writing it changes nothing, on any page, in any block, until
   a merchant sets the property. That is the same contract `var(--sf-sec-pad, 0px)` keeps for
   padding, arrived at from the other end: there, the old literal is the fallback; here, the old
   behaviour has a keyword of its own. */
.sf-h1 { font-family: var(--sf-font-heading, inherit); font-size: var(--sf-sec-head-size, 30px); font-weight: var(--sf-sec-head-weight, var(--sf-heading-weight)); line-height: var(--sf-sec-head-lh, inherit); text-transform: var(--sf-sec-head-case, inherit); font-style: var(--sf-sec-head-style, inherit); letter-spacing: var(--sf-heading-tracking); color: var(--sf-sec-head-colour, inherit); margin: 0 0 20px; }
.sf-h2 { font-family: var(--sf-font-heading, inherit); font-size: var(--sf-sec-head-size, 22px); font-weight: var(--sf-sec-head-weight, 700); line-height: var(--sf-sec-head-lh, inherit); text-transform: var(--sf-sec-head-case, inherit); font-style: var(--sf-sec-head-style, inherit); color: var(--sf-sec-head-colour, inherit); margin: 36px 0 16px; }

.sf-hero { text-align: center; padding: 56px 20px; background: var(--sf-surface); border-radius: var(--sf-radius); margin-bottom: 12px; }
.sf-hero-title { font-family: var(--sf-font-heading, inherit); font-size: var(--sf-sec-head-size, 40px); font-weight: var(--sf-sec-head-weight, var(--sf-heading-weight)); line-height: var(--sf-sec-head-lh, inherit); text-transform: var(--sf-sec-head-case, inherit); font-style: var(--sf-sec-head-style, inherit); letter-spacing: var(--sf-heading-tracking); color: var(--sf-sec-head-colour, inherit); margin: 0 0 8px; }
.sf-hero-sub { font-size: var(--sf-sec-text-size, 17px); line-height: var(--sf-sec-text-lh, inherit); color: var(--sf-sec-text-colour, var(--sf-muted)); margin: 0 0 22px; }

.sf-btn { display: inline-block; padding: 12px 22px; border-radius: 999px; background: var(--sf-brand); color: var(--sf-brand-ink); font-weight: 700; font-size: 15px; border: 0; cursor: pointer; }
.sf-btn:hover { filter: brightness(1.06); }

.sf-search { display: flex; gap: 10px; margin-bottom: 24px; max-width: 640px; flex-wrap: wrap; align-items: center; }
.sf-sort { display: inline-flex; align-items: center; gap: 6px; }
.sf-sort-lbl { font-size: 13px; color: var(--sf-muted); }
.sf-sort select { padding: 10px 12px; border: 1px solid var(--sf-border); border-radius: 999px; font: inherit; background: var(--sf-surface); color: var(--sf-ink); cursor: pointer; }
.sf-search input { flex: 1; padding: 11px 14px; border: 1px solid var(--sf-border); border-radius: 999px; font: inherit; background: var(--sf-surface); color: var(--sf-ink); }
.sf-search button { padding: 11px 20px; border-radius: 999px; border: 0; background: var(--sf-ink); color: var(--sf-bg); font-weight: 700; cursor: pointer; }

.sf-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 18px; }
/* ⚠️ RAISED ELEMENTS SIT ON `--sf-surface`, NOT `--sf-bg`. The card, the inputs, the nav panel, the
   mobile drawers, the cookie bar and the age-gate card all painted themselves with the PAGE ground,
   and it looked right for one reason only: this shop had the two tokens the wrong way round —
   `bg: #ffffff`, `surface: #f2f7ff` — so "the page colour" happened to be white.
   Setting the shop's real ground (#f2f7ff, measured off vapeandgo.co.uk) turned every card the same
   colour as the page behind it and they vanished. `surface` is documented as exactly this — "Product
   cards, the basket summary and form panels sit on this" — so the CSS had drifted from the token
   model rather than the model being wrong. Nothing that was white stops being white. */
.sf-card { display: flex; flex-direction: column; border: 1px solid var(--sf-border); border-radius: var(--sf-radius); overflow: hidden; background: var(--sf-surface); box-shadow: var(--sf-shadow); transition: transform .12s ease, box-shadow .12s ease; }
.sf-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(20,24,40,.12); }
.sf-card-thumb { aspect-ratio: 4 / 3; display: grid; place-items: center; font-size: 42px; font-weight: 800; color: var(--sf-brand); background: var(--sf-accent); overflow: hidden; }
/* Product image fills its tile (cover) — the placeholder letter keeps the grid centering. */
.sf-thumb-pic { display: block; width: 100%; height: 100%; }
.sf-thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.sf-card-body { display: flex; flex-direction: column; gap: 4px; padding: 14px; }
.sf-card-brand { font-size: 12px; font-weight: 600; color: var(--sf-faint); text-transform: uppercase; letter-spacing: .04em; }
/* ⚠️ THE CARD'S WORDS READ THE BLOCK'S TWO TEXT CONTROLS (P1 audit A2). product_grid, carousel and
   featured_products all OFFERED "Text size" and "Text weight", and the only body text in those
   blocks is the cards — whose sizes were literals, so both controls saved, coerced cleanly and
   painted nothing. Blank is byte-identical: the fallbacks ARE the old literals. The price rides
   ONE pixel above the chosen size and keeps its 800 weight, so the merchant's number scales the
   card without flattening its hierarchy; the brand kicker stays its own 12px label on purpose —
   it is a label, not body text, and tying it to a body-size control would be a third meaning. */
.sf-card-title { font-size: var(--sf-sec-text-size, 15px); font-weight: var(--sf-sec-text-weight, 600); color: var(--sf-ink); }
/* Section (brand-card) subtitle — a short teaser under a child collection's name (0044). */
.sf-card-sub { font-size: 13px; color: var(--sf-muted); line-height: 1.4; }
.sf-card-price { font-size: calc(var(--sf-sec-text-size, 15px) + 1px); font-weight: 800; margin-top: 4px; }
.sf-card-from { font-size: 12px; font-weight: 600; color: var(--sf-faint); }
/* "Was" (compare-at) strikethrough — the crossed-out prior price beside the current one. */
.sf-card-was { font-size: 12.5px; font-weight: 600; color: var(--sf-faint); }
.sf-more { margin-top: 20px; }
.sf-more a { color: var(--sf-sec-link-colour, var(--sf-brand)); font-weight: 600; text-decoration: var(--sf-sec-link-deco, none); }
.sf-empty { color: var(--sf-muted); padding: 40px 0; }

.sf-crumb { margin-bottom: 18px; }
.sf-crumb a { color: var(--sf-muted); font-size: 14px; }
/* Multi-segment breadcrumb trail (0038). Inline list with a "/" separator between crumbs. */
.sf-crumb-list { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; list-style: none; margin: 0; padding: 0; }
.sf-crumb-item { display: inline-flex; align-items: center; font-size: 14px; color: var(--sf-muted); }
.sf-crumb-item + .sf-crumb-item::before { content: "/"; margin-right: 6px; color: var(--sf-muted); opacity: .55; }
.sf-crumb-item a { color: var(--sf-muted); text-decoration: none; }
.sf-crumb-item a:hover { color: var(--sf-text, inherit); text-decoration: underline; }
.sf-crumb-item [aria-current="page"] { color: var(--sf-text, inherit); font-weight: 600; }
.sf-pd { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 40px; }
@media (max-width: 760px) { .sf-pd { grid-template-columns: 1fr; gap: 24px; } }
.sf-pd-media { aspect-ratio: 1 / 1; display: grid; place-items: center; font-size: 96px; font-weight: 800; color: var(--sf-brand); background: var(--sf-accent); border-radius: var(--sf-radius); }
/* Real product photo (0039). object-position is set inline from the image's focal point. */
.sf-pd-media.has-photo { margin: 0; padding: 0; font-size: 0; overflow: hidden; }
.sf-pd-media.has-photo picture, .sf-pd-media.has-photo img { display: block; width: 100%; height: 100%; }
.sf-pd-media.has-photo img { aspect-ratio: 1 / 1; object-fit: cover; border-radius: var(--sf-radius); }
.sf-pd-thumbs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.sf-pd-thumb { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; border: 1px solid var(--sf-border, #e3e6ec); }
.sf-pd-brand { font-size: 13px; font-weight: 600; color: var(--sf-faint); text-transform: uppercase; letter-spacing: .05em; }
.sf-pd-title { font-size: 28px; font-weight: var(--sf-heading-weight); letter-spacing: var(--sf-heading-tracking); margin: 6px 0 6px; }
/* Per-store presentation (0049): marketing sub-headline under the title. */
.sf-pd-subhead { font-size: 16px; color: var(--sf-muted); margin: 0 0 12px; line-height: 1.4; }
.sf-pd-price { font-size: 26px; font-weight: 800; }
.sf-pd-vat { font-size: 14px; font-weight: 500; color: var(--sf-faint); }
.sf-pd-from { font-size: 14px; font-weight: 600; color: var(--sf-faint); }
.sf-pd-was { font-size: 16px; font-weight: 600; color: var(--sf-faint); margin-left: 6px; }
.sf-nicotine { margin: 16px 0; padding: 10px 14px; border-radius: 10px; background: var(--sf-warn-bg); color: var(--sf-warn-ink); font-size: 13px; font-weight: 600; }
/* Statutory TRPR/MHRA warning surfaces. Site-wide standing bar (below header, every page)
   + the per-product compliance note on the PDP. Both use the warn palette. */
.sf-compliance { background: var(--sf-warn-bg); color: var(--sf-warn-ink); border-bottom: 1px solid var(--sf-border); font-size: 12.5px; font-weight: 600; }
.sf-compliance .sf-wrap { display: block; padding: 8px 0; }
/* Above the footer the hairline belongs on TOP: a bottom border there draws a line between the
   notice and the footer it is sitting on, which reads as the footer having a stray edge. */
.sf-compliance-bottom { border-bottom: 0; border-top: 1px solid var(--sf-border); }
.sf-compliance-note { margin: 12px 0; padding: 10px 14px; border-radius: 10px; border: 1px solid var(--sf-warn-ink); background: var(--sf-warn-bg); color: var(--sf-warn-ink); font-size: 12.5px; }
/* Per-store logo + trader-identity footer (0059). */
.sf-logo { max-height: 38px; width: auto; display: block; }
.sf-footer-legal { font-size: 12px; color: var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-faint))); margin: 4px 0 0; }

/* Coupon redemption on the basket (#17). */
.sf-coupon-form { display: flex; gap: 8px; margin: 4px 0 14px; }
.sf-coupon-form input { flex: 1; min-width: 0; padding: 9px 12px; border: 1px solid var(--sf-border); border-radius: 8px; font: inherit; background: var(--sf-surface); color: var(--sf-ink); }
.sf-coupon-applied { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 4px 0 14px; padding: 8px 12px; border-radius: 8px; background: var(--sf-accent); }
.sf-coupon-tag { font-size: 13px; font-weight: 700; color: var(--sf-brand); }
.sf-coupon-rm { display: inline; }
.sf-coupon-err { margin: -8px 0 12px; font-size: 12.5px; color: #b42318; }
/* Not an error — the saving is real, it just is not settled until we know who is buying. */
.sf-coupon-note { margin: -8px 0 12px; font-size: 12.5px; color: var(--sf-muted, #667085); }
.sf-sum-save b { color: var(--sf-brand); }

/* Cookie notice (#9) — bottom bar, hidden until consent.js opens it (no flash for returning users). */
.sf-cookie { display: none; position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; gap: 14px; align-items: center; justify-content: center; flex-wrap: wrap; padding: 12px 18px; background: var(--sf-ink); color: var(--sf-bg); font-size: 13px; box-shadow: 0 -2px 12px rgba(20,24,40,.18); }
.sf-cookie.is-open { display: flex; }
.sf-cookie-text { max-width: 720px; }
.sf-cookie-ok { padding: 8px 18px; border: 0; border-radius: 999px; background: var(--sf-brand); color: var(--sf-brand-ink); font: inherit; font-weight: 700; cursor: pointer; }

/* Admin in-environment preview banner (StorefrontController::preview only). */
.sf-preview-bar { background: #4f46e5; color: #fff; font-size: 12.5px; font-weight: 600; }
.sf-preview-bar .sf-wrap { display: block; padding: 8px 0; }
.sf-add { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; margin: 22px 0; }
.sf-field { display: flex; flex-direction: column; gap: 5px; font-size: 13px; font-weight: 600; color: var(--sf-muted); }
.sf-field select, .sf-field input { padding: 10px 12px; border: 1px solid var(--sf-border); border-radius: 10px; font: inherit; background: var(--sf-surface); color: var(--sf-ink); }
.sf-qty input { width: 84px; }
.sf-btn-add { align-self: flex-end; }

/* Variant picker styles (0049) — chips / swatches. Zero-JS radios; the native radio is hidden and the
   label is the tap target, so it stays keyboard-accessible + posts name=variant_id like the dropdown. */
.sf-picker { flex-direction: row; flex-wrap: wrap; gap: 8px; }
.sf-picker .sf-pick { position: relative; display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; border: 1px solid var(--sf-border); border-radius: 999px; cursor: pointer; font-weight: 600; color: var(--sf-ink); background: var(--sf-surface); }
.sf-picker .sf-pick input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.sf-picker .sf-pick.is-on { border-color: var(--sf-brand); background: var(--sf-accent); box-shadow: 0 0 0 1px var(--sf-brand); }
.sf-picker .sf-pick:focus-within { box-shadow: 0 0 0 2px var(--sf-brand); }
.sf-picker .sf-pick-price { font-weight: 700; color: var(--sf-muted); font-size: 13px; }
/* Swatches: squarer tiles that lead with the variant name; reuse the accent tint per option. */
.sf-picker-swatches { gap: 10px; }
.sf-picker-swatches .sf-pick { flex-direction: column; align-items: flex-start; gap: 2px; border-radius: 12px; min-width: 96px; }

/* Trust-badge strip (0049) — plain text/emoji, no external images. */
.sf-trust { list-style: none; display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 16px 0 0; padding: 0; }
.sf-trust-item { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--sf-muted); }
.sf-trust-ic { font-size: 15px; }

/* Specifications table (0049). */
.sf-specs { margin: 32px 0 0; }
.sf-specs-h { font-size: 20px; font-weight: 800; margin: 0 0 12px; }
.sf-specs-table { width: 100%; max-width: 560px; border-collapse: collapse; font-size: 14px; }
.sf-specs-table th, .sf-specs-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--sf-border); vertical-align: top; }
.sf-specs-table th { width: 40%; color: var(--sf-muted); font-weight: 600; }
.sf-specs-table td { color: var(--sf-ink); }
.sf-pd-desc { margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--sf-border); color: var(--sf-ink); }
.sf-pd-desc img { max-width: 100%; height: auto; }

/* ── THE FOOTER IS A BAND TOO (L4) ─────────────────────────────────────────────────────────────
   EVERY FALLBACK IS THE LITERAL THIS RULE ALREADY CARRIED, and two of them differ from the
   header's for reasons worth writing down.

   `transparent` — because `.sf-footer` declared NO background and transparent is what it computed
   to. NOT `var(--sf-bg)`: that is the HEADER's fallback because `.sf-header` really did say
   `background: var(--sf-bg)`, and copying it here would swap a computed transparent for #ffffff on
   three trading shops.

   `var(--sf-muted)` — because this rule's own colour is `var(--sf-muted)` and always has been. The
   header falls back to `inherit` only because it declared no colour at all.

   The BORDER takes the band's colour when there is one, so a merchant's band does not get a
   theme-grey seam drawn across the top of it. With nothing set it resolves to `var(--sf-border)`,
   byte for byte what it was.

   `--sf-band-*` AND NEVER `--sf-chr-*` — custom properties inherit, and every widget inside this
   footer reads the chrome namespace. Nothing inside the footer reads `--sf-band-*`; the three rules
   that do are all SIBLINGS of <footer>, never descendants. */
.sf-footer { border-top: var(--sf-band-bd-w, 1px) solid var(--sf-band-bd-c, var(--sf-band-bg, var(--sf-border))); padding: 28px 0;
  /* PC-4: minimum height only — the footer draws whatever it holds and this adds room, never a
     clip. Deliberately no centring: a footer's content belongs at its top. */
  min-height: var(--sf-band-h, auto);
  background: var(--sf-band-bg, transparent);
  /* The band's picture (slice 14): the longhands come AFTER the shorthand on purpose — the
     shorthand resets them, so this order is what lets colour and picture coexist. */
  background-image: var(--sf-band-img, none); background-size: var(--sf-band-img-size, cover);
  background-position: var(--sf-band-img-pos, center); background-repeat: var(--sf-band-img-repeat, no-repeat);
  color: var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-muted)));
  font-size: 13px; }
.sf-footer-sub { color: var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-faint))); margin-top: 4px; }
.sf-404 { text-align: center; padding: 60px 20px; }
.sf-404 h1 { font-size: 28px; margin-bottom: 8px; }
.sf-404 p { color: var(--sf-muted); margin-bottom: 20px; }

/* Basket (slice 2) */
.sf-flash { padding: 10px 14px; border-radius: 10px; margin-bottom: 16px; font-size: 14px; }
.sf-flash-ok { background: var(--sf-accent); color: var(--sf-ink); }
.sf-flash-err { background: var(--sf-warn-bg); color: var(--sf-warn-ink); }
/* Availability re-check (#16): the basket changed under the shopper — a warning, not an error. */
.sf-flash-warn { background: #fff6e5; color: #7a4b00; border: 1px solid #f0d9a8; }
@media (prefers-color-scheme: dark) { .sf-flash-warn { background: #3a2c10; color: #f3d9a3; border-color: #5c471c; } }
.sf-cart-low { display: block; font-size: 12px; font-weight: 700; color: #c0392b; margin-top: 2px; }
/* The basket total is goods only — delivery is decided at checkout from the real address. */
.sf-sum-note { font-weight: 400; font-size: 11px; color: var(--sf-faint); }
.sf-cart { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 32px; align-items: start; }
/* The desktop rule above uses `minmax(0, 1fr)`; this one dropped it, so a wide child could push the
   whole basket column past the viewport with nothing able to shrink. Kept symmetrical on purpose. */
@media (max-width: 760px) { .sf-cart { grid-template-columns: minmax(0, 1fr); } }
.sf-cart-lines { display: flex; flex-direction: column; }
.sf-cart-line { display: grid; grid-template-columns: 64px minmax(0,1fr) auto auto auto; gap: 16px; align-items: center; padding: 16px 0; border-bottom: 1px solid var(--sf-border); }
/* ⚠️ ON A PHONE THE NAME COLUMN COMPUTED TO LITERALLY 0px. The thumb (64) plus three content-sized
   `auto` columns — quantity 112.8, line total 70, remove 48.6 — plus four 16px gaps is a 359px floor
   that cannot shrink, so `minmax(0,1fr)` was the only thing left to give, and it gave everything.
   "Smok X-Force Coil — 0.6 ohm" then rendered as seven one-word lines running into the quantity box.
   Below 560px the row becomes two rows: thumb + name across the top, controls beneath. */
@media (max-width: 560px) {
  .sf-cart-line {
    grid-template-columns: 64px minmax(0, 1fr) auto;
    grid-template-areas: "thumb info info" "thumb qty total";
    row-gap: 10px; align-items: start;
  }
  .sf-cart-thumb { grid-area: thumb; }
  .sf-cart-info { grid-area: info; }
  .sf-cart-line > form { grid-area: qty; }
  .sf-cart-line > .sf-cart-total, .sf-cart-line > [class*="total"] { grid-area: total; justify-self: end; }
}
.sf-cart-thumb { width: 64px; height: 64px; border-radius: 8px; overflow: hidden; display: grid; place-items: center; background: var(--sf-accent); color: var(--sf-brand); font-weight: 800; font-size: 22px; }
.sf-cart-info { display: flex; flex-direction: column; gap: 3px; }
.sf-cart-name { font-weight: 600; }
.sf-cart-unit { font-size: 13px; color: var(--sf-faint); }
.sf-cart-qtyform { display: flex; align-items: center; gap: 6px; }
.sf-cart-qtyform input { width: 62px; padding: 7px 8px; border: 1px solid var(--sf-border); border-radius: 8px; font: inherit; background: var(--sf-surface); color: var(--sf-ink); }
.sf-cart-linetotal { font-weight: 800; min-width: 70px; text-align: right; }
.sf-link { background: none; border: 0; color: var(--sf-brand); font: inherit; font-size: 13px; cursor: pointer; padding: 0; }
.sf-link-danger { color: #c0392b; }
.sf-cart-summary { border: 1px solid var(--sf-border); border-radius: var(--sf-radius); padding: 20px; background: var(--sf-surface); }
/* "Add to your order" offers (Decision #86b) — inside the lines column. */
.sf-addon-strip { margin-top: 22px; }
.sf-addon-strip-h { font-size: 15px; font-weight: 700; margin: 0 0 10px; }
.sf-addon-strip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.sf-addon-strip-card { display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--sf-border); border-radius: var(--sf-radius); padding: 12px; background: var(--sf-surface); }
.sf-addon-strip-name { font-size: 13.5px; font-weight: 600; color: inherit; text-decoration: none; }
.sf-addon-strip-name:hover { text-decoration: underline; }
.sf-addon-strip-label { font-size: 12px; color: var(--sf-muted, #6b7280); }
.sf-addon-strip-price { font-size: 13px; }
.sf-addon-strip-price s { color: var(--sf-muted, #6b7280); margin-right: 4px; }
.sf-addon-strip-free { color: var(--sf-accent, #16a34a); }
.sf-btn-sm { padding: 6px 12px; font-size: 13px; align-self: flex-start; }
.sf-cart-uptag { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: var(--sf-surface-2, #f1f3f5); font-size: 11px; font-weight: 600; color: var(--sf-muted, #6b7280); vertical-align: 1px; }
.sf-cart-summary h2 { margin: 0 0 14px; font-size: 18px; }
.sf-sum-row { display: flex; justify-content: space-between; padding: 7px 0; font-size: 14px; color: var(--sf-muted); }
.sf-sum-row b { color: var(--sf-ink); }
.sf-sum-total { border-top: 1px solid var(--sf-border); margin-top: 6px; padding-top: 12px; font-size: 17px; }
.sf-sum-total b { font-size: 19px; }
.sf-btn-checkout { width: 100%; margin-top: 14px; }
.sf-continue { display: inline-block; margin-top: 12px; font-size: 14px; color: var(--sf-muted); }

/* Checkout + confirmation (slice 3) */
.sf-checkout-form { display: flex; flex-direction: column; gap: 14px; }
.sf-checkout-form .sf-h2:first-of-type { margin-top: 0; }
/* ⚠️ `minmax(0, 1fr)`, NOT `1fr` — a bare `1fr` floors at the content's MIN-CONTENT width, and two
   text inputs are ~171.5px each, so this row could not shrink below 357px. On a 360px phone that put
   17px of every input, the country select and the "Place order" button off the right of the screen,
   on the page that takes the money. Below 480px the two fields stack instead, because Town/city and
   Postcode side by side at 340px are too narrow to type into comfortably even once they fit. */
.sf-field-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
@media (max-width: 480px) { .sf-field-row { grid-template-columns: 1fr; } }
.sf-field em { font-weight: 400; color: var(--sf-faint); font-style: normal; }
.sf-agecheck { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; background: var(--sf-surface); border: 1px solid var(--sf-border); border-radius: 10px; font-size: 14px; cursor: pointer; }
.sf-agecheck input { margin-top: 2px; width: 16px; height: 16px; }
.sf-paynote { font-size: 12px; color: var(--sf-faint); margin: 8px 0 0; }

/* Delivery options at checkout (owner shipping architecture, module 4). Radio cards: the
   whole row is the label, so the tap target is the option, not the 16px dot. */
.sf-ship-options { display: grid; gap: 8px; }
.sf-ship-option { display: flex; align-items: center; gap: 12px; padding: 12px 14px; background: var(--sf-surface); border: 1px solid var(--sf-border); border-radius: 10px; cursor: pointer; }
.sf-ship-option:has(input:checked) { border-color: var(--sf-brand); box-shadow: 0 0 0 1px var(--sf-brand) inset; }
.sf-ship-option input { width: 16px; height: 16px; flex: none; accent-color: var(--sf-brand); }
.sf-ship-main { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; }
.sf-ship-main b { font-size: 15px; }
.sf-ship-est { font-size: 12px; color: var(--sf-muted); }
.sf-ship-price { font-weight: 800; font-size: 15px; white-space: nowrap; }
.sf-ship-note { font-size: 12px; color: var(--sf-faint); margin: 8px 0 0; }
.sf-checkout-blocked { padding: 12px 14px; border-radius: 10px; border: 1px solid #e6c7c2; background: #fdf2f0; color: #8a2a1c; font-size: 14px; }
.sf-confirm { text-align: center; padding: 48px 20px; max-width: 520px; margin: 0 auto; }
.sf-confirm-tick { width: 64px; height: 64px; margin: 0 auto 20px; border-radius: 50%; background: var(--sf-brand); color: var(--sf-brand-ink); font-size: 34px; line-height: 64px; font-weight: 800; }
.sf-confirm h1 { font-size: 26px; margin: 0 0 14px; }
.sf-confirm-num { font-size: 16px; }
.sf-confirm-total { font-size: 16px; color: var(--sf-muted); }
.sf-confirm-note { color: var(--sf-muted); font-size: 14px; margin: 14px 0 24px; }

/* Availability (stock policy — Decision #52) */
.sf-avail { display: inline-block; margin: 4px 0 2px; font-size: 13px; font-weight: 700; }
.sf-avail.is-in { color: #1a7f4b; }
.sf-avail.is-out { color: #c0392b; }
/* Availability flag states (0051, Decision #84): coming-soon teaser + buyable pre-order. */
.sf-avail.is-soon { color: #8a6d1f; }
.sf-avail.is-preorder { color: #2f6feb; }
.sf-pd-tbc { color: var(--sf-faint); font-weight: 800; }
.sf-card-badge { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; vertical-align: middle; }
.sf-badge-soon { background: #fbf1d6; color: #8a6d1f; }
.sf-badge-preorder { background: #e6efff; color: #2f6feb; }
.sf-btn-add:disabled { opacity: .5; cursor: not-allowed; }
/* `.sf-card-oos` LIVED HERE AND NOTHING EVER EMITTED IT — a sold-out badge rule with no badge, the
   signature defect's mirror at the stylesheet layer (P1 audit A2). Deleted rather than wired: the
   card KNOWS its availability, but a badge appearing on every sold-out card of three trading shops
   with no merchant action would be a release redesigning a shop. When sold-out badging ships, it
   ships as a control, and its rule returns here WITH its emitter. */
.sf-card { position: relative; }

/* CMS page (Phase 7 storefront) */
/* ⚠️ A MEASURE NEEDS ITS OWN CENTRING. `max-width` alone narrows the column and then leaves it
   flush against the start edge, so a 760px article sat inside a 1440px `.sf-wrap` with 680px of
   dead space to its right — under a header and above a footer that are both centred. Nobody saw
   it while the only page here was a 109-character seed stub; it showed the moment the shop's real
   policy pages landed. The shop this clones centres the same content, so this pairs the two. */
.sf-page { max-width: var(--sf-cms-measure, 1176px); margin-inline: auto; }
/* The trail belongs to the column it introduces. `_breadcrumbs.html.twig` is a SIBLING of the
   article and is shared with the full-width product and collection pages, where flush-left is
   right — so the measure is granted here by what FOLLOWS the trail, not by a class on it. Without
   this the crumb starts 320px to the left of the <h1> it sits above.
   ⚠️ `~`, NOT `+`. The partial emits its BreadcrumbList <script type="application/ld+json"> as a
   SIBLING right after the <nav>, so the article is never the adjacent one and `+` matched nothing
   — a rule that was present in the stylesheet and silently did nothing. */
.sf-crumb:has(~ .sf-page), .sf-crumb:has(~ .sf-posts) { max-width: var(--sf-cms-measure, 1176px); margin-inline: auto; }
/* ── BRANDS A–Z and the BLOG INDEX (0164) ───────────────────────────────────────────────────────
   The two index pages the footer linked and nothing answered. They are deliberately different
   shapes: the brands page is a WAYFINDING index and takes the full wrap so its columns can breathe,
   while the blog list is PROSE and takes the same 760px measure a CMS page does — which is why the
   crumb rule above names `.sf-posts` alongside `.sf-page` and the brands page is not in it. */
.sf-az-count { color: var(--sf-muted); font-size: 14px; margin: 0 0 16px; }
.sf-az-jump { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 28px; }
.sf-az-jump a { display: inline-flex; align-items: center; justify-content: center;
  min-width: 34px; height: 34px; padding: 0 8px; border: 1px solid var(--sf-border);
  border-radius: 8px; font-size: 14px; font-weight: 700; line-height: 1;
  color: var(--sf-ink); text-decoration: none; }
.sf-az-jump a:hover { border-color: var(--sf-brand); color: var(--sf-brand); }
/* ⚠️ THE HEADER IS `position: sticky`, so an anchor jump lands the letter UNDERNEATH it and the
   reader sees the wrong group. `--sf-header-sticky` is the same variable the filter rail offsets
   by, and it collapses to 0 on a shop whose header is static. */
.sf-az-group { margin: 0 0 30px; scroll-margin-top: calc(var(--sf-header-sticky, 0px) + 16px); }
.sf-az-letter { font-size: 20px; font-weight: 700; margin: 0 0 12px; padding-bottom: 6px;
  border-bottom: 2px solid var(--sf-border); }
.sf-az-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px 24px;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }
.sf-az-list li { display: flex; align-items: baseline; gap: 8px; padding: 5px 0; }
.sf-az-list a { color: var(--sf-ink); text-decoration: none; }
.sf-az-list a:hover { color: var(--sf-brand); text-decoration: underline; }
.sf-az-n { color: var(--sf-muted); font-size: 12px; font-variant-numeric: tabular-nums; }

/* ── THE BLOG INDEX, MEASURED OFF THE LIVE SHOP ────────────────────────────────────────────────
   Cards on a grid, not a prose column: three across at 452px with a 30px gap, white on the page
   ground, 15px radius and a soft lift. `auto-fill` rather than a hard `repeat(3, …)` so the same
   rule answers a narrow laptop and a phone without a second breakpoint.
   ⚠️ The 760px prose measure this used to have is GONE deliberately — the live index is a grid at
   full container width, and the crumb rule above therefore hands `.sf-posts` the same measure as a
   CMS page only for its heading, which is why the grid sits inside it rather than beside it. */
.sf-posts { max-width: var(--sf-cms-measure, 1176px); margin-inline: auto; }
.sf-post-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 30px;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); }
.sf-post-card { display: flex; flex-direction: column; padding: 26px 28px;
  background: var(--sf-surface, #fff); border-radius: 15px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, .1); }
.sf-post-card-title { font-size: 26px; line-height: 1.25; font-weight: 500; margin: 0 0 10px;
  text-wrap: balance; }
.sf-post-card-title a { color: var(--sf-cms-title, var(--sf-brand)); text-decoration: none; }
.sf-post-card-title a:hover { text-decoration: underline; }
.sf-post-card-excerpt { margin: 0 0 14px; color: var(--sf-cms-ink, #2b3a34); line-height: 1.7;
  font-size: 15px; }
/* Pushed to the card's foot so a short excerpt and a long one still line their dates up. */
.sf-post-card-meta { margin: auto 0 0; color: #707882; font-size: 13.6px; }
.sf-post-meta { margin: 0; color: var(--sf-muted); font-size: 13px; }
/* Shared with post.html.twig, which has shipped this class since the blog did and never styled it. */
.sf-post-tag { display: inline-block; padding: 2px 8px; border-radius: 999px;
  background: var(--sf-accent, #f6f7f9); color: var(--sf-muted); font-size: 12px; }

/* ── CONTENT-PAGE TYPE, MEASURED OFF vapeandgo.co.uk AT 1440px ──────────────────────────────────
   Every number below was read out of the live shop's own computed styles rather than chosen, so
   "make it match" has one answer instead of an opinion. What is NOT here is anything global: the
   shop's font (Inclusive Sans), brand colour, 1400px container and #f2f7ff page ground are THEME
   TOKENS on store 1, because they belong to the shop and not to this page type.

   ⚠️ THE CONTENT PAGES RUN AT 16px WHILE THE SHOP RUNS AT 14px. That is the live shop's own split —
   `body` is 14px there and the policy column is 16px/1.8 — and it is why these sizes are literals
   rather than `em`. Scaling them off the base would make the reading column shrink with the chrome. */
/* ⚠️ SCOPED TO THE CONTENT PAGES, not added to the shared `.sf-h1`. That rule is the heading on the
   listing, the basket, search and the brands index too, and those follow the shop's own
   `heading_weight` token. This is the ONE page type whose title the live shop draws differently:
   36px/72px at weight 600 in the brand colour, not the shop's 800. */
.sf-page > .sf-h1, .sf-posts > .sf-h1 {
  font-size: 36px; line-height: 72px; font-weight: 600;
  color: var(--sf-cms-title, var(--sf-brand)); margin: 0 0 8px;
}
.sf-page-body { font-size: 16px; line-height: 1.8; color: var(--sf-cms-ink, #2b3a34); }
.sf-page-body h2 { font-size: 24px; line-height: 48px; font-weight: 600; margin: 30px 0 14px;
  color: var(--sf-cms-heading, #253d4e); }
.sf-page-body h3 { font-size: 20px; line-height: 36px; font-weight: 600; margin: 26px 0 10px;
  color: var(--sf-cms-heading, #253d4e); }
.sf-page-body h4 { font-size: 17px; line-height: 30px; font-weight: 600; margin: 22px 0 8px;
  color: var(--sf-cms-heading, #253d4e); }
.sf-page-body p { margin: 0 0 16px; }
.sf-page-body ul, .sf-page-body ol { margin: 0 0 18px; padding-left: 34px; }
.sf-page-body ul { list-style: disc; }
.sf-page-body li { margin: 0 0 8px; }
.sf-page-body a { color: var(--sf-sec-link-colour, var(--sf-brand)); text-decoration: var(--sf-sec-link-deco, underline); }
.sf-page-body img { max-width: 100%; height: auto; border-radius: 8px; }

/* Footer nav — links to published CMS pages (About, Terms, Privacy…). */
.sf-footer-nav { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-bottom: 10px; }
.sf-footer-nav a { color: inherit; opacity: .85; text-decoration: none; font-size: 13px; }
.sf-footer-nav a:hover { opacity: 1; text-decoration: underline; }

/* Collection landing page — intro description above the product grid. */
.sf-collection-desc { color: #52525b; max-width: 60ch; margin: -6px 0 18px; line-height: 1.5; }

/* ── Reviews (module 37) — product page rating summary + list + submit form. ── */
.sf-rating-link { display: inline-flex; align-items: center; gap: 7px; margin: 2px 0 8px; font-size: 13px; color: #52525b; text-decoration: none; }
.sf-rating-link:hover { color: #18181b; }
.sf-stars { color: #d4d4d8; font-size: 14px; letter-spacing: 1px; white-space: nowrap; }
.sf-stars span.on { color: #e8a13a; }
.sf-stars-lg { font-size: 20px; }

.sf-reviews { max-width: 760px; margin: 40px auto 0; padding-top: 28px; border-top: 1px solid #e4e4e7; }
.sf-reviews-h { font-size: 20px; font-weight: 700; margin: 0 0 16px; }
.sf-review-flash { padding: 10px 14px; border-radius: 8px; background: #ecfdf5; color: #047857; font-size: 14px; margin-bottom: 16px; }
.sf-review-flash.is-error { background: #fef2f2; color: #b91c1c; }
.sf-reviews-summary { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.sf-reviews-avg { font-size: 34px; font-weight: 800; line-height: 1; }
.sf-reviews-count { font-size: 13px; color: #71717a; }
.sf-review-list { list-style: none; margin: 0 0 20px; padding: 0; display: flex; flex-direction: column; gap: 18px; }
.sf-review { border-bottom: 1px solid #f4f4f5; padding-bottom: 16px; }
.sf-review-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.sf-review-title { font-size: 15px; }
.sf-review-body { margin: 7px 0; font-size: 14.5px; line-height: 1.55; color: #3f3f46; }
.sf-review-by { font-size: 12.5px; color: #71717a; }
.sf-verified { color: #047857; font-weight: 600; }
.sf-review-reply { margin-top: 8px; padding: 9px 12px; background: #f4f4f5; border-radius: 8px; font-size: 13.5px; color: #3f3f46; }
.sf-reviews-none { color: #71717a; font-size: 14px; margin: 0 0 18px; }

.sf-review-form-wrap { margin-top: 6px; }
.sf-review-form-wrap > summary { list-style: none; display: inline-block; cursor: pointer; }
.sf-review-form-wrap > summary::-webkit-details-marker { display: none; }
.sf-btn-review { background: #fff; color: #18181b; border: 1px solid #d4d4d8; }
.sf-review-form { margin-top: 16px; display: flex; flex-direction: column; gap: 12px; max-width: 560px; }
.sf-review-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.sf-review-form .sf-field span em { color: #a1a1aa; font-style: normal; font-size: 12px; }
@media (max-width: 560px) { .sf-review-row { grid-template-columns: 1fr; } }

/* ── Build-your-own bundle configurator (Phase B) ── */
.sf-slot { display: flex; flex-direction: column; gap: 5px; }
.sf-slot > span { font-size: 13px; font-weight: 600; color: var(--sf-muted); }
.sf-slot > span em { color: var(--sf-brand); font-style: normal; font-weight: 700; }
.sf-slot select { padding: 10px 12px; border: 1px solid var(--sf-border); border-radius: 10px; font: inherit; background: var(--sf-surface); color: var(--sf-ink); max-width: 100%; }
.sf-bundle-total { margin: 4px 0 2px; font-size: 15px; color: var(--sf-muted); }
.sf-bundle-total b { color: var(--sf-ink); font-size: 18px; }
.sf-add-bundle { gap: 14px; }

/* Cart: per-slot configuration breakdown + blocked-checkout note. */
.sf-cart-config { list-style: none; margin: 4px 0 2px; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.sf-cart-config li { font-size: 12.5px; color: var(--sf-muted); }
.sf-cart-config-slot { font-weight: 600; color: var(--sf-faint); }
.sf-checkout-blocked { font-size: 13px; color: var(--sf-warn-ink); background: var(--sf-warn-bg); border-radius: 10px; padding: 10px 12px; margin: 0 0 10px; }
.sf-btn-checkout[disabled] { opacity: .5; cursor: not-allowed; }

/* Product tags (effective = shared + this store's extra). */
.sf-pd-tags { list-style: none; display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0 0; padding: 0; }
.sf-tag { font-size: 12px; color: var(--sf-muted); background: var(--sf-surface); border: 1px solid var(--sf-border); border-radius: 999px; padding: 4px 11px; }

/* Rich-text description: code + hr + alignment (from the sanitised HTML). */
.sf-pd-desc code { font-family: ui-monospace, monospace; font-size: .92em; background: var(--sf-surface); border: 1px solid var(--sf-border); border-radius: 4px; padding: .05em .35em; }
.sf-pd-desc hr { border: 0; border-top: 1px solid var(--sf-border); margin: 1.2em 0; }

/* Product FAQ (per-store). */
.sf-faq { margin-top: 34px; }
.sf-faq-h { font-size: 22px; margin: 0 0 16px; }
.sf-faq-list { margin: 0; }
.sf-faq-item { border-top: 1px solid var(--sf-border); padding: 16px 0; }
.sf-faq-q { font-weight: 700; font-size: 16px; color: var(--sf-ink); margin: 0 0 6px; }
.sf-faq-a { margin: 0; color: var(--sf-muted); line-height: 1.6; }

/* Rich-text tables + code blocks (0039/#70) in storefront descriptions. */
.sf-pd-desc table, .sf-page-body table, .sf-collection-desc table {
  border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 15px;
}
.sf-pd-desc th, .sf-pd-desc td,
.sf-page-body th, .sf-page-body td,
.sf-collection-desc th, .sf-collection-desc td {
  border: 1px solid var(--sf-border); padding: 8px 10px; text-align: left; vertical-align: top;
}
.sf-pd-desc th, .sf-page-body th, .sf-collection-desc th { background: var(--sf-accent, #f6f7f9); font-weight: 700; }
.sf-pd-desc pre, .sf-page-body pre, .sf-collection-desc pre {
  background: var(--sf-accent, #f6f7f9); border: 1px solid var(--sf-border);
  border-radius: 8px; padding: 12px 14px; margin: 16px 0; overflow-x: auto;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 14px; line-height: 1.5;
  white-space: pre-wrap;
}
.sf-pd-desc code, .sf-page-body code, .sf-collection-desc code {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: .9em;
  background: var(--sf-accent, #f2f3f5); padding: 1px 5px; border-radius: 4px;
}
.sf-pd-desc pre code, .sf-page-body pre code, .sf-collection-desc pre code { background: none; padding: 0; }

/* ── Interim age gate (self-declared 18+; roadmap). Hidden by default so verified visitors
   never flash it; agegate.js adds .is-open when the sf_age_ok cookie is absent. ── */
.sf-agegate { display: none; position: fixed; inset: 0; z-index: 1000; padding: 20px;
  background: rgba(15, 23, 42, .72); align-items: center; justify-content: center; }
.sf-agegate.is-open { display: flex; }
html.sf-agegate-lock, html.sf-agegate-lock body { overflow: hidden; }
/* ⚠️ THE CARD BACKGROUND WAS HARD-CODED #fff AND THE 18+ GATE WAS ILLEGIBLE IN DARK MODE (0104).
   Nothing sets `color` here, so the text inherits --sf-ink from body — and under
   `prefers-color-scheme: dark` that is #eef1f6 while the card stayed #ffffff. 1.2:1. Every dark-OS
   shopper on all three trading shops has been shown a blank white age gate, and this is a LAUNCH
   GATE: the one dialog on the site that is not allowed to be unreadable.
   Taking both colours from the theme fixes that and is a no-op everywhere else — --sf-bg defaults to
   #ffffff, which is what this said — and it is also what lets a dark preset (`after_dark`) exist at
   all, since a token-set page with a hard-coded white gate has exactly the same 1.2:1 failure in
   daylight. ThemePresetContrastTest measures the pair for every preset so the eleventh cannot
   reintroduce it. */
.sf-agegate-card { background: var(--sf-bg); color: var(--sf-ink); width: 100%; max-width: 420px; border-radius: 14px;
  padding: 28px; text-align: center; box-shadow: 0 20px 60px rgba(0, 0, 0, .35); }
.sf-agegate-h { margin: 0 0 10px; font-size: 22px; }
/* …and the "No" button's outline was #d0d5dd, 1.5:1 on white — a control boundary under WCAG 1.4.11,
   which wants 3:1. --sf-border now clears that (and follows the theme, so it cannot be a light-grey
   leftover on a dark card). */
.sf-agegate-no { background: none; border: 1px solid var(--sf-border); color: inherit; }
.sf-agegate-declined { display: none; }
.sf-agegate[data-declined] .sf-agegate-main { display: none; }
.sf-agegate[data-declined] .sf-agegate-declined { display: block; }

/* ===== Merchandising badges (0052, MODULE 14) — CSP-safe: fixed swatch/shape/size classes ===== */
.badge-chip { display: inline-flex; align-items: center; gap: 4px; line-height: 1; white-space: nowrap; vertical-align: middle; }
.badge-chip-ic { font-size: 1.05em; }
.badge-size-sm { font-size: 10px; padding: 2px 7px; }
.badge-size-md { font-size: 11px; padding: 3px 9px; }
.badge-size-lg { font-size: 13px; padding: 5px 13px; }
.badge-shape-pill { border-radius: 999px; }
.badge-shape-rounded { border-radius: 6px; }
.badge-shape-square { border-radius: 2px; }
.badge-shape-tag { border-radius: 4px 4px 4px 0; }
.badge-chip.is-upper { text-transform: uppercase; letter-spacing: .04em; }
.badge-chip.is-bold { font-weight: 800; }
.badge-chip:not(.is-bold) { font-weight: 600; }
.badge-sw-0  { background: #2A6FDB; color: #fff; } .badge-sw-1  { background: #D64545; color: #fff; }
.badge-sw-2  { background: #1F8A5B; color: #fff; } .badge-sw-3  { background: #E08600; color: #fff; }
.badge-sw-4  { background: #7C3AED; color: #fff; } .badge-sw-5  { background: #C026A6; color: #fff; }
.badge-sw-6  { background: #0E9488; color: #fff; } .badge-sw-7  { background: #334155; color: #fff; }
.badge-sw-8  { background: #101322; color: #fff; } .badge-sw-9  { background: #DBEBFF; color: #1e40af; }
.badge-sw-10 { background: #FBE7E9; color: #b91c1c; } .badge-sw-11 { background: #E3F5EC; color: #037f4c; }
.badge-sw-12 { background: #FDEDDB; color: #b45309; } .badge-sw-13 { background: #EDE9FE; color: #6d28c0; }
.badge-sw-14 { background: #F1F5F9; color: #334155; }
/* Overlay badges pinned to a card image corner. */
.sf-card-thumb { position: relative; }
.badge-pos { position: absolute; z-index: 1; }
.badge-pos-tl { top: 6px; left: 6px; } .badge-pos-tr { top: 6px; right: 6px; }
.badge-pos-bl { bottom: 6px; left: 6px; } .badge-pos-br { bottom: 6px; right: 6px; }
/* Inline badges next to a card title / PDP title. */
.sf-card-title .badge-chip { margin-left: 6px; }
.sf-pd-badges { display: inline-flex; flex-wrap: wrap; gap: 5px; margin-left: 8px; vertical-align: middle; }
/* Built-in SVG badge icons (MODULE 14) — inherit the chip's text colour + size. */
.badge-chip-ic { display: inline-flex; align-items: center; }
.badge-chip-ic svg, .badge-svg { width: 1em; height: 1em; display: block; }
/* Uploaded badge chip image (0053) — replaces the icon; same-origin, CSP-safe. */
.badge-chip-img { width: 1.2em; height: 1.2em; object-fit: contain; display: block; border-radius: 2px; }


/* ══ PAGE SECTIONS (0069) ══════════════════════════════════════════════════════════════════════
   A store composes its home page from these. Every one uses the design tokens, so a section
   automatically follows whatever the store set in the design panel. */

/* ── Slideshow ──────────────────────────────────────────────────────────────────────────────── */
.sf-slider { position: relative; border-radius: var(--sf-radius); overflow: hidden; margin-bottom: 20px; }
.sf-slider-short .sf-slide { min-height: 220px; }
.sf-slider-medium .sf-slide { min-height: 340px; }
.sf-slider-tall .sf-slide { min-height: 460px; }

.sf-slides { position: relative; }
/* Slides stack; only the active one is shown. `position: relative` on the active slide is what
   gives the container its height, so no fixed height has to be guessed. */
.sf-slide { display: none; position: relative; align-items: center; padding: 36px 28px;
  background: var(--sf-surface); color: var(--sf-ink); }
.sf-slide.is-active { display: flex; }
/* The image arrives via --sf-slide-bg (set by slider.js through the CSSOM — the CSP forbids inline
   styles). A dark scrim keeps the text readable whatever the photo is. */
.sf-slide.has-bg { background-image: var(--sf-shade, linear-gradient(rgba(0, 0, 0, .38), rgba(0, 0, 0, .38))), var(--sf-slide-bg);
  background-size: cover; background-position: center; color: #fff; }
.sf-slide-in { max-width: 640px; width: 100%; }

/* ── "Follow the picture" (owner request, 2026-09-02) ─────────────────────────────────────────
   The three fixed heights above pair with `background-size: cover`, which fills the band by
   CUTTING THE PICTURE'S SIDES OFF. Measured on a 3.02:1 promotional banner in a 1033px viewport:
   31% of the width gone, taking the first word of the headline and the "3" out of "3 FOR £14.99".
   Finished artwork carries its own words, and they sit near the edges.

   So here the picture is a real `<img>` at `width: 100%; height: auto` and the slide has NO fixed
   height — narrowing the window shrinks the banner instead of trimming it, which is what a
   promotional slideshow has to do. The `<img>` is also the only version of this block that can be
   the LCP element: a CSS background cannot carry `fetchpriority`.

   The text layer floats over the picture rather than sitting beside it, so a slide that has BOTH
   artwork and a heading still reads as one band. */
.sf-slider-fit .sf-slide { min-height: 0; padding: 0; }
/* The background (and its scrim) would otherwise paint behind a transparent PNG. */
.sf-slider-fit .sf-slide.has-bg { background-image: none; color: var(--sf-ink); }
.sf-slide-img { display: block; width: 100%; height: auto; flex: 0 0 100%; }
.sf-slider-fit .sf-slide-in { position: absolute; inset: 0; display: flex; flex-direction: column;
  justify-content: center; max-width: none; padding: 36px 28px; pointer-events: none; }
/* …but the button inside it must still be clickable. */
.sf-slider-fit .sf-slide-in > * { pointer-events: auto; }
.sf-slider-fit .sf-slide-in:empty { display: none; }
/* ⚠️ AND THE DEVICE-EXCLUDED SLIDES MUST NOT SET THE HEIGHT. In fade mode every slide shares one
   grid cell (`grid-area: 1 / 1`), so the cell is as tall as the TALLEST slide — including the ones
   opacity:0 and skipped by slider.js. With a fixed min-height that cost nothing; here it is fatal.
   Measured: four 1585×525 desktop slides beside four 365×525 mobile ones rendered a 1371px band of
   empty space at 953px wide, because the portrait art at width:100% is 1371px tall.

   `display: none` and NOT the `.sf-only-*` classes at :711, which are `!important` and would beat
   `.sf-slide.is-active` — the bug the template's own comment records, where hiding the active slide
   collapsed the whole slideshow. These are scoped to `.sf-slider-fit`, so no existing shop moves.

   THE 760/761 BREAKPOINT IS slider.js's, not a fresh choice: it gates on
   `matchMedia('(max-width: 760px)')` (slider.js:46). Any other number opens a band where the script
   thinks a slide is showing while the stylesheet has hidden it. */
@media (min-width: 761px) { .sf-slider-fit .sf-slide[data-sf-only="mobile"] { display: none; } }
@media (max-width: 760px) { .sf-slider-fit .sf-slide[data-sf-only="desktop"] { display: none; } }
.sf-slide-left { justify-content: flex-start; text-align: left; }
.sf-slide-center { justify-content: center; text-align: center; }
.sf-slide-right { justify-content: flex-end; text-align: right; }
.sf-slide-h { font-family: var(--sf-font-heading, inherit); font-size: var(--sf-sec-head-size, 34px); font-weight: var(--sf-sec-head-weight, var(--sf-heading-weight)); line-height: var(--sf-sec-head-lh, inherit); text-transform: var(--sf-sec-head-case, inherit); font-style: var(--sf-sec-head-style, inherit); letter-spacing: var(--sf-heading-tracking); color: var(--sf-sec-head-colour, inherit); margin: 0 0 8px; }
.sf-slide-sub { font-size: var(--sf-sec-text-size, 17px); line-height: var(--sf-sec-text-lh, inherit); margin: 0 0 18px; opacity: .92; }
/* The accessible name for a slide's background picture (0098). Present in the accessibility tree
   and invisible on the page — NOT `display: none`, which would remove it from the tree and make the
   whole thing pointless. `pointer-events: none` so naming a photograph never swallows a click on
   the button in front of it. */
.sf-slide-alt { position: absolute; inset: 0; pointer-events: none; }
.sf-slide-btn { margin-top: 4px; }
@media (max-width: 600px) {
  .sf-slide { padding: 24px 18px; }
  .sf-slide-h { font-size: var(--sf-sec-head-size, 24px); }
  .sf-slide-sub { font-size: var(--sf-sec-text-size, 15px); }
}

.sf-slider-arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
  display: grid; place-items: center; width: 40px; height: 40px; padding: 0 0 4px;
  border: none; border-radius: 50%; background: rgba(255, 255, 255, .86); color: #1a1f2b;
  font-size: 26px; line-height: 1; cursor: pointer; }
.sf-slider-arrow:hover { background: #fff; }
.sf-slider-prev { left: 12px; }
.sf-slider-next { right: 12px; }

.sf-slider-dots { position: absolute; left: 0; right: 0; bottom: 12px; z-index: 2;
  display: flex; justify-content: center; gap: 8px; }
.sf-slider-dot { width: 9px; height: 9px; padding: 0; border: none; border-radius: 50%;
  background: rgba(255, 255, 255, .55); cursor: pointer; }
.sf-slider-dot.is-active { background: #fff; width: 22px; border-radius: 999px; }
/* Focus must stay visible on top of a photo of any colour. */
.sf-slider-arrow:focus-visible, .sf-slider-dot:focus-visible { outline: 3px solid var(--sf-brand); outline-offset: 2px; }

/* Slideshow options (0084). --sf-shade is the ONE knob: the fallback in the rule above is the shade
   slideshows have always had, so a store that never opens this setting looks exactly as it did. */
.sf-shade-none { --sf-shade: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0)); }
.sf-shade-light { --sf-shade: linear-gradient(rgba(255, 255, 255, .66), rgba(255, 255, 255, .66)); }
.sf-shade-dark { --sf-shade: linear-gradient(rgba(0, 0, 0, .62), rgba(0, 0, 0, .62)); }
.sf-shade-gradient { --sf-shade: linear-gradient(rgba(0, 0, 0, 0) 28%, rgba(0, 0, 0, .78)); }
/* A light shade means a light photo, so the white text has to come back to near-black — otherwise
   this option produces the exact unreadable slide it exists to fix. */
.sf-shade-light .sf-slide.has-bg { color: #16181d; }
.sf-shade-light .sf-slide.has-bg .sf-slider-dot { background: rgba(0, 0, 0, .45); }
.sf-shade-light .sf-slide.has-bg .sf-slider-dot.is-active { background: #16181d; }

.sf-slider-v-top .sf-slide { align-items: flex-start; }
.sf-slider-v-middle .sf-slide { align-items: center; }
.sf-slider-v-bottom .sf-slide { align-items: flex-end; }

/* Fade rather than cut. slider.js only toggles `is-active`, so the transition is CSS's job: the
   slides are stacked and the inactive ones are transparent instead of display:none. */
.sf-slider-x-fade .sf-slides { display: grid; }
.sf-slider-x-fade .sf-slide { display: flex; grid-area: 1 / 1; opacity: 0; visibility: hidden;
  transition: opacity .45s ease, visibility .45s; }
.sf-slider-x-fade .sf-slide.is-active { opacity: 1; visibility: visible; }
@media (prefers-reduced-motion: reduce) {
  .sf-slider-x-fade .sf-slide { transition: none; }
}

/* ── Image + text banner ────────────────────────────────────────────────────────────────────── */
.sf-banner { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px;
  align-items: var(--sf-sec-align-y, center); margin: 28px 0; }
.sf-banner-right .sf-banner-media { order: 2; }
.sf-banner-media img { display: block; width: 100%; height: auto; border-radius: var(--sf-radius); }
/* PICTURE SHAPE (0098) — the layout-shift fix. `height: auto` above means the browser cannot know
   how tall this banner will be until the photograph arrives, so everything under it jumps when it
   does. A chosen shape reserves the box before a byte of the picture has downloaded.

   `aspect-ratio` on the CONTAINER plus `object-fit: cover` on the image, not `aspect-ratio` on the
   image itself: the image is `height: auto`, and a ratio there would fight the height rather than
   set it. Opt-in per banner — a banner left on "whatever shape the file is" carries no class and
   matches none of these. */
.sf-banner-media[class*="sf-imgr-"] > img { height: 100%; object-fit: cover; }
.sf-imgr-16x9 { aspect-ratio: 16 / 9; }
.sf-imgr-3x2 { aspect-ratio: 3 / 2; }
.sf-imgr-4x3 { aspect-ratio: 4 / 3; }
.sf-imgr-1x1 { aspect-ratio: 1 / 1; }
.sf-imgr-3x4 { aspect-ratio: 3 / 4; }
.sf-banner-h { font-family: var(--sf-font-heading, inherit); font-size: var(--sf-sec-head-size, 26px); font-weight: var(--sf-sec-head-weight, var(--sf-heading-weight)); line-height: var(--sf-sec-head-lh, inherit); text-transform: var(--sf-sec-head-case, inherit); font-style: var(--sf-sec-head-style, inherit); letter-spacing: var(--sf-heading-tracking); color: var(--sf-sec-head-colour, inherit); margin: 0 0 10px; }
.sf-banner-text { font-size: 16px; line-height: var(--sf-sec-text-lh, 1.65); color: var(--sf-muted); margin: 0 0 16px; }
@media (max-width: 760px) {
  .sf-banner { grid-template-columns: 1fr; gap: 16px; }
  .sf-banner-right .sf-banner-media { order: 0; }
}

/* Banner options (0084) ------------------------------------------------------------------------ */
.sf-banner-t-center .sf-banner-body { text-align: center; }
.sf-banner-compact { margin: 16px 0; }
.sf-banner-tall .sf-banner-body { padding: 22px 0; }
.sf-banner-bg-tint, .sf-banner-bg-brand { padding: 26px; border-radius: var(--sf-radius); }
.sf-banner-bg-tint { background: var(--sf-surface); }
.sf-banner-bg-brand { background: var(--sf-brand); color: var(--sf-brand-ink); }
/* The muted body colour is a grey chosen against the page, not against the brand — on a brand
   background it can land anywhere from invisible to fine, so it inherits instead. */
.sf-banner-bg-brand .sf-banner-text { color: inherit; opacity: .92; }
.sf-banner-bg-brand .sf-btn { background: var(--sf-brand-ink); color: var(--sf-brand); }

/* One wide banner: the picture is still a real <img> in the document, stacked behind the words. */
.sf-banner-behind { position: relative; grid-template-columns: 1fr; overflow: hidden;
  border-radius: var(--sf-radius); }
.sf-banner-behind .sf-banner-media { position: absolute; inset: 0; }
.sf-banner-behind .sf-banner-media img { width: 100%; height: 100%; object-fit: cover;
  border-radius: 0; }
.sf-banner-behind .sf-banner-body { position: relative; z-index: 1; padding: 56px 32px;
  max-width: 640px; color: #fff; }
.sf-banner-behind.sf-banner-t-center .sf-banner-body { max-width: 720px; margin: 0 auto; }
.sf-banner-behind .sf-banner-text { color: inherit; opacity: .92; }
.sf-banner-behind.sf-banner-compact .sf-banner-body { padding: 32px 24px; }
.sf-banner-behind.sf-banner-tall .sf-banner-body { padding: 92px 32px; }
/* The shade is a layer over the image, not a filter on it — an `opacity` on the <img> would wash
   out the picture itself rather than sit between it and the text. */
.sf-banner-behind.sf-shade-light::before, .sf-banner-behind.sf-shade-dark::before {
  content: ''; position: absolute; inset: 0; z-index: 1; }
.sf-banner-behind.sf-shade-light::before { background: rgba(255, 255, 255, .68); }
.sf-banner-behind.sf-shade-dark::before { background: rgba(0, 0, 0, .5); }
.sf-banner-behind.sf-shade-light .sf-banner-body { color: #16181d; }
@media (max-width: 760px) {
  .sf-banner-behind .sf-banner-body { padding: 34px 20px; }
  .sf-banner-behind.sf-banner-tall .sf-banner-body { padding: 52px 20px; }
}

/* Button styles (0084) — a solid button on every block is loud; sometimes a link is the right
   weight for a secondary message. */
.sf-btn-outline { background: transparent; color: var(--sf-brand);
  box-shadow: inset 0 0 0 2px currentColor; }
.sf-btn-link { background: transparent; color: var(--sf-brand); padding: 4px 0;
  border-radius: 0; text-decoration: underline; text-underline-offset: 4px; }
.sf-banner-behind .sf-btn-outline, .sf-banner-behind .sf-btn-link,
.sf-banner-bg-brand .sf-btn-outline, .sf-banner-bg-brand .sf-btn-link { color: inherit;
  background: transparent; }

/* ── Where a block shows (0084; tablet tier added by header plan §7 Q2, 2026-08-12) ──────────── */
/* 760px is the same breakpoint the banner and the rest of the layout already switch at, so
   "desktop only" means the same thing everywhere rather than a second, competing idea of small. */
@media (max-width: 760px) { .sf-only-desktop { display: none !important; } }
@media (min-width: 761px) { .sf-only-mobile { display: none !important; } }

/*
 * ⚠️ THE TABLET TIER IS ADDITIVE, AND THAT IS THE WHOLE DESIGN (§7 Q2, option (b)).
 *
 * `desktop` STILL MEANS ≥761 and still includes tablets. Nothing stored changes meaning — `show_on`
 * is a real column with real rows on three trading shops, and redefining `desktop` to ≥1025 (option
 * (a)) would silently blank every widget already set to "Computers only" across a 264px band of
 * devices their customers actually hold. A merchant who wants the old behaviour keeps it by doing
 * nothing, which is this codebase's "blank means today" rule applied to an enum.
 *
 * So this is ONE new class and no edits to the two above it. A merchant who wants tablet-only picks
 * it deliberately; everyone else is untouched.
 *
 * THE UPPER BOUND IS 1024, and it is verifiable rather than notional: the admin's two preview docks
 * now agree that a tablet is 834 (§7 Q6), which lands inside 761–1024, while the desktop frame is
 * 1280 and the phone 390. Each of the three preview widths falls in exactly one tier — which is what
 * makes "Tablets only" a promise the previews can actually demonstrate.
 */
@media (max-width: 760px), (min-width: 1025px) { .sf-only-tablet { display: none !important; } }

/* Only ever seen in the admin's live preview: on the real shop a block outside its dates is not
   rendered at all, so this class cannot reach a customer. */
.sf-scheduled-off { position: relative; opacity: .55; outline: 2px dashed rgba(120, 120, 130, .55);
  outline-offset: -2px; }

/* ── Promises row ───────────────────────────────────────────────────────────────────────────── */
.sf-usps { margin: 28px 0; }
/* The row/column gap reaches here (0098); the COARSE `--sf-sec-gap` deliberately does not, and the
   reason is a live shop rather than a principle. `gap`'s help has always said "cards, promises,
   logos", but the property only ever reached `.sf-cols` and `.sf-carousel` — and store 1's home
   page already has a promises row with `gap: 10` saved against it. Wiring the coarse pair in here
   would silently re-space a trading shop's home page as a side effect of adding two new controls,
   which is precisely the change this slice promised not to make. The literal stays the fallback,
   the two new controls work, and closing the coarse gap is an owner's decision with its own diff. */
.sf-usp-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--sf-sec-gap-row, 14px) var(--sf-sec-gap-col, 14px); align-items: var(--sf-sec-align-y, normal); }
.sf-usp { padding: 16px 18px; background: var(--sf-surface); border: 1px solid var(--sf-border);
  border-radius: var(--sf-radius); }
.sf-usp-title { display: block; font-size: 15px; font-weight: 700; margin-bottom: 4px; }
.sf-usp-text { font-size: var(--sf-sec-text-size, 13.5px); line-height: var(--sf-sec-text-lh, 1.55); color: var(--sf-sec-text-colour, var(--sf-muted)); }

/* ── Text block ─────────────────────────────────────────────────────────────────────────────── */
.sf-textblock { margin: 28px 0; }
.sf-textblock-center { text-align: center; }
.sf-textblock-p { font-size: var(--sf-sec-text-size, 16px); line-height: var(--sf-sec-text-lh, 1.7); color: var(--sf-sec-text-colour, var(--sf-muted)); margin: 0 0 12px; max-width: 72ch; }
.sf-textblock-center .sf-textblock-p { margin-left: auto; margin-right: auto; }


/* ── Announcement bar + chrome options (0071) ───────────────────────────────────────────────── */
/* PC-4: `min-height`, NEVER `height` — a band can be given room but can never clip its message;
   a long announcement on a phone wraps and grows past the number. `auto` is the initial value, so
   an unset var changes nothing anywhere. `align-content` centres the message in the extra room on
   block containers (Chrome 123+/Safari 17.4+/FF 125+); older engines ignore it and the message
   sits at the top of the strip under its own padding — degraded, never broken. */
.sf-announce { background: var(--sf-band-bg, var(--sf-brand)); color: var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-brand-ink))); font-size: 13.5px; font-weight: 600; min-height: var(--sf-band-h, auto); align-content: center; }
.sf-announce .sf-wrap { display: block; padding: 9px 20px; text-align: center; }
/* ⚠️ UNDERLINED AT REST, not only on hover (owner ask, 2026-09-10). This bar is a coloured strip
   of bold text; when it happens to be a link, nothing else in it says so. Hover is not the place to
   disclose that — a touch shopper has no hover, and the strip's whole job is to be tapped. The
   reference underlines it permanently for the same reason. Hover then thickens rather than adds,
   so the state still changes for a pointer. A non-link announcement is a `<span>` and is untouched
   by both rules. */
.sf-announce a.sf-wrap { text-decoration: underline; text-underline-offset: 3px; }
.sf-announce a.sf-wrap:hover { text-decoration-thickness: 2px; }
/* A store can opt out of the sticky header — on a short page it only steals height. */
.sf-header.is-static { position: static; }


/* ── Header & footer SLOT LAYOUT (handoff §3) ───────────────────────────────────────────────────
   Only reached when a shop has rearranged something: `layout.html.twig` keeps drawing its original
   header and footer until `chrome._layout.<element>.classic` goes false, so none of this applies to
   a store that has never opened the panel.

   Every value here arrives as a class the SERVER chose from a closed list — four header regions, two
   footer regions, two alignments. That is why the whole feature needs no custom property and no
   JavaScript: there is no merchant-typed value anywhere in it, so there is nothing for CSSOM to
   set. */
.sf-header-in.sf-hdr { display: grid; grid-template-columns: 1fr auto 1fr; gap: 18px; }
.sf-hdr-r { display: flex; align-items: center; flex-wrap: wrap; gap: 22px; min-width: 0; }
.sf-hdr-left { justify-content: flex-start; }
.sf-hdr-centre { justify-content: center; }
.sf-hdr-right { justify-content: flex-end; }

/* ── THE MERCHANT'S OWN ALIGNMENT, PER REGION (the header's half of the footer's `footer_align`) ──
   The three rules above are the region's POSITION IN THE BAR and set a justification to match its
   name. That name stops describing what is inside the moment a merchant puts a text line in Left and
   wants it centred, or a menu in the second row and wants it flush left — so these override it.

   ⚠️ TWO CLASSES DEEP ON PURPOSE. `.sf-hdr-left` and `.sf-hdr-a-centre` are both one class, so a
   single-class rule would win or lose on source order alone. Compounding with `.sf-hdr-r` (and with
   `.sf-hdr-row2`, which is not an `.sf-hdr-r`) makes the merchant's choice beat the region's default
   by specificity, which is a fact about the selectors rather than about where they happen to sit in
   this file.

   THE CLASS IS EMITTED ONLY WHERE THE MERCHANT CHOSE SOMETHING OTHER THAN THE DEFAULT
   (ChromeSettings::alignClass), so a shop that never opened the control serves the markup it always
   served and none of this applies to it.

   AND IT HOLDS ON A PHONE. The stacking rules further down centre every region at 640px, which is
   the right default for a 375px screen — but a merchant who explicitly asked for Left asked for it
   everywhere, and these rules outrank that one by the same specificity. A region with no choice keeps
   the phone default, because it emits no class at all. */
.sf-hdr-r.sf-hdr-a-left, .sf-hdr-row2.sf-hdr-a-left, .sf-hdr-row3.sf-hdr-a-left { justify-content: flex-start; }
.sf-hdr-r.sf-hdr-a-centre, .sf-hdr-row2.sf-hdr-a-centre, .sf-hdr-row3.sf-hdr-a-centre { justify-content: center; }
.sf-hdr-r.sf-hdr-a-right, .sf-hdr-row2.sf-hdr-a-right, .sf-hdr-row3.sf-hdr-a-right { justify-content: flex-end; }

/* ══ THE BAR'S OWN LAYOUT (owner, 2026-08-04: "laout set option customized in header") ═══════════
 *
 * The arrangement above decides WHICH widget is in which region. These decide what the regions
 * themselves ARE — the three numbers that were written into this file and identical on all three
 * shops: `1fr auto 1fr`, `gap: 18px`, and the hairline under the bar.
 *
 * ⚠️ EVERY ONE IS AN ADDITIVE OVERRIDE OF A RULE THAT STILL STATES THE DEFAULT.
 *
 * `.sf-header-in.sf-hdr` above is untouched and still says `1fr auto 1fr`. Nothing here changes what
 * a shop that has chosen nothing is served — no class is emitted for a blank value, so the cascade
 * never reaches these rules at all. That is what keeps three trading shops' markup AND stylesheet
 * what they were, and it is asserted rather than hoped for.
 *
 * ⚠️ AND WHY THERE IS NO HEIGHT HERE. The bar's height is the `header_h` THEME TOKEN, read from
 * `:root` as `--sf-header-h` — by this header AND by the sticky filter rail 250 lines below. A class
 * on `<header>` could not reach `.sf-filters`, so a height set here would leave the rail holding a
 * gap for a header that no longer exists. See the note in ChromeSettings::COMMON.
 *
 * Two classes deep for the same reason the alignment rules above are: `.sf-header-in.sf-hdr` is two
 * classes, so a one-class override would win or lose on source order alone rather than by a fact
 * about the selectors. */

/* ── How the top row divides ─────────────────────────────────────────────────────────────────── */
.sf-header-in.sf-hdr-c-even { grid-template-columns: 1fr 1fr 1fr; }
.sf-header-in.sf-hdr-c-centre-wide { grid-template-columns: auto 1fr auto; }
/* Each part on its own line — one column, and the regions centre themselves within it. This is what
   the phone layout already does at 760px; this applies it at every width, for a shop that wants a
   tall centred header. The regions' own alignment still wins, because those rules are two classes
   deep too and a merchant who asked for Left asked for it here as well. */
.sf-header-in.sf-hdr-c-stack {
  grid-template-columns: 1fr;
  /* The fixed bar height is the one thing a stack cannot honour: three rows do not fit in 62px, and
     forcing them would clip the logo. It grows instead, which is what the phone layout does. */
  height: auto;
  padding-top: 12px;
  padding-bottom: 12px;
  gap: 10px;
}
.sf-header-in.sf-hdr-c-stack .sf-hdr-r { justify-content: center; }

/* ── Space between the parts ─────────────────────────────────────────────────────────────────── */
.sf-header-in.sf-hdr-g-tight { gap: 8px; }
.sf-header-in.sf-hdr-g-roomy { gap: 34px; }

/* ── Width of the bar ────────────────────────────────────────────────────────────────────────────
   `sf-wrap` is still on the element and still states the contained width; this widens it rather than
   the template dropping a class, so the default markup is untouched and a merchant switching back
   gets exactly what they had. The side padding stays: content hard against a screen edge is not what
   "edge to edge" means to anyone who asks for it. */
.sf-header-in.sf-hdr-w-full, .sf-hdr-row2.sf-hdr-w-full, .sf-hdr-row3.sf-hdr-w-full { max-width: none; }
/* The footer's twin (F1) — its colour always ran edge to edge; this frees the content. */
.sf-footer.sf-ftr-w-full .sf-wrap { max-width: none; }

/* ── The bottom edge ─────────────────────────────────────────────────────────────────────────── */
.sf-header.sf-hdr-e-none { border-bottom: 0; }
.sf-header.sf-hdr-e-shadow { border-bottom: 0; box-shadow: 0 2px 10px rgba(16, 22, 40, .10); }
/* The three curated elevations, by name. The literal travels in --sf-band-shadow via the map seam
   (ChromeWidgets::SHADOW_CSS), so this rule carries NO shadow value of its own — there is one
   elevation vocabulary and this is a reader of it, never a second copy. The legacy 'shadow' option
   keeps its own pinned rule above, untouched. */
.sf-header.sf-hdr-e-subtle, .sf-header.sf-hdr-e-soft, .sf-header.sf-hdr-e-lifted {
  border-bottom: 0; box-shadow: var(--sf-band-shadow, none); }

/* ── The second row ──────────────────────────────────────────────────────────────────────────────
   Drawn only when something is placed on it — the template does not render the row otherwise, so
   neither of these can produce a stray line across an empty header. `--sf-accent` rather than a
   literal, so a tinted band follows the shop's own brand and its dark mode. */
.sf-hdr-row2.sf-hdr-r2-line { border-top: 1px solid var(--sf-border); padding-top: 10px; }
.sf-hdr-row2.sf-hdr-r2-tint {
  /* ⚠️ THE MERCHANT'S OWN COLOUR WINS OVER THE TINT (header plan slice 3). This was a bare
     `var(--sf-accent)` at (0,2,0), which out-ranked the row's own `--sf-band-bg` rule at (0,1,0) —
     so the panel offered "Tinted band" and "Second row background" side by side and setting both
     silently discarded the colour. Two controls, one of them lying. A shop with the tint and no
     colour renders identically: the fallback IS the old value. */
  background: var(--sf-band-bg, var(--sf-accent));
  /* The band reaches the full width of the bar, not just the contained column — a tint that stopped
     at 1120px would read as a misaligned box rather than as a band. 20px is `.sf-wrap`'s own side
     padding, repeated rather than inherited: this rule replaces the shorthand that carried it, and a
     token that does not exist (`--sf-sec-pad-x` is a SECTION's padding, not the page gutter) would
     silently fall back to something narrower on every shop. */
  max-width: none;
  padding: 10px 20px;
}

/* The MENU's own alignment settings (0073) were written for a header with one nav in it, where
   pushing yourself around with `margin-right: auto` or absolute centring was the only way to move.
   Inside a region the region does the placing, and an absolutely positioned nav would leave its
   column entirely — so those two are neutralised here rather than being fought with widths. */
.sf-hdr .sf-nav-left, .sf-hdr-row2 .sf-nav-left { margin-right: 0; padding-left: 0; }
.sf-hdr .sf-nav-center, .sf-hdr-row2 .sf-nav-center { position: static; left: auto; transform: none; }

.sf-hdr-row2 { display: flex; align-items: center; justify-content: center; flex-wrap: wrap;
  gap: 22px; padding-bottom: 12px;
  /* PC-4: the row's own height — the ONE band height that joins `--sf-header-total`, because this
     row is inside the sticky header. min-height, so content can outgrow the number (the sum then
     undercounts, which is the failure mode every auto-height row already has — never an overlap).
     The row is already a centring flexbox, so the extra room needs no new alignment rule. */
  min-height: var(--sf-band-h, auto);
  /* ⚠️ THE SECOND ROW IS A BAND OF ITS OWN — alectrofag.co.uk puts its menu in a strip a different
     navy from the row above it, and that was unreachable while a header had one colour.
     `--sf-band-bg` INHERITS from the header, so a merchant who colours only the bar gets a row that
     paints the same colour rather than a hole; one who colours the row as well gets a second band.
     Transparent when neither is set, which is exactly today. */
  background: var(--sf-band-bg, transparent);
  /* The computed pairing, when a background is set and no ink — same chain the footer's nine
     readers have carried all along (header plan slice 2). */
  color: var(--sf-band-ink, var(--sf-band-ink-auto, inherit)); }

/* A promise's own glyph. Sized to the text beside it rather than to itself, so a merchant's 192px
   artwork and our 16px registry icon sit on the same line at the same weight. */
.sf-pr-img { height: 1.35em; width: auto; vertical-align: -0.28em; margin-right: 7px; }
/* An IMPORTED icon is inline markup, not an <img>, so it is sized by its wrapper. The SVG
   inside carries only a viewBox (the sanitiser drops width/height on the root for exactly
   this reason), which makes it take the box it is given rather than one it chose. */
/* A merchant's own glyph, wherever one can replace one of ours. Sized to the text beside it, and
   the SVG inside carries only a viewBox so it takes the box rather than choosing one. */
.sf-ic { display: inline-block; height: 1.15em; width: 1.15em; vertical-align: -0.18em; margin-right: 6px; }
.sf-ic > svg { height: 100%; width: 100%; display: block; }
.sf-pr-svg { display: inline-block; height: 1.35em; width: 1.35em; vertical-align: -0.28em; margin-right: 7px; }
.sf-pr-svg > svg { height: 100%; width: 100%; display: block; }

/* ── THE TRUSTPILOT BOX ───────────────────────────────────────────────────────────────────────
   ONE root for both elements, like the promises row it usually sits beside: it is a shared widget
   drawing the same markup in a header slot and a footer slot, and a second class would be two names
   for one thing.

   Almost nothing to style, and that is the point — everything inside is an iframe Trustpilot owns,
   so this sets the box it lands in and stops. `min-width` because their script measures the element
   before it draws: a flex row can collapse an empty div to zero, and a zero-width box renders a
   zero-width rating. */
.sf-chr-slot > .sf-tp { display: block; min-width: 180px; line-height: 0; }

/* ── THE THIRD ROW — the promises strip ───────────────────────────────────────────────────────
   The same band as the row above it, and deliberately not a variant of it: every measured shop in
   the reference set carries a strip of short promises under its header, and on the shop this was
   matched against it is WHITE under a #035C56 bar. Two bands could hold the promises OR the menu,
   never both, which is why this row exists at all.

   `--sf-band-bg` inherits from the header exactly as row 2's does, so a shop that colours only the
   bar gets a third row painting the same colour rather than a hole. It carries no `min-height` var
   of its own beyond the band slot, and it is NOT summed into `--sf-header-total` — the sum exists
   for the sticky bar's offset, and a strip that scrolls away is not part of what stays. */
.sf-hdr-row3 { display: flex; align-items: center; justify-content: center; flex-wrap: nowrap;
  gap: 22px;
  /* ⚠️ NO VERTICAL PADDING OF ITS OWN — BUT THE HORIZONTAL GUTTER IS NOT OPTIONAL. The band
     already has a height slot, and vertical padding on top of it is added to the number a merchant
     typed — a 64px strip rendering 78. The row is a centring flexbox, so `min-height` alone puts the
     content where the padding was trying to.

     `padding: 0` SHORTHAND TOOK THE SIDES WITH IT, and that was a real defect: `.sf-wrap` gives every
     other header row a 20px gutter, so this one alone clipped its content against the viewport edge
     — a promise cut through the middle of its first word with no margin at all. The gutter is what
     lines this row up with the bar above it and the page below it. */
  padding: 0 20px;
  min-height: var(--sf-band-h, auto);
  background: var(--sf-band-bg, transparent);
  color: var(--sf-band-ink, var(--sf-band-ink-auto, inherit)); }

/* WIDGET PLACEMENTS (0097). A region holds an ordered list of placements now, not four fixed slots,
   and two of the kinds draw the merchant's own words: a text line and a button. Both are styled to
   the header's existing scale rather than given a scale of their own, because a header that grows a
   taller row the moment a widget is added is a header that reflows on three trading shops.

   Still not one custom property and still no JavaScript: `text` and `button` carry WORDS, and words
   need no CSS to be chosen. The day a widget carries a colour is the day this needs a per-page
   stylesheet — which is exactly why ChromeSettings does not offer one yet. */
.sf-hdr-text { font-size: 13.5px; color: var(--sf-muted); white-space: nowrap; }
.sf-hdr-btn { flex: none; padding: 7px 15px; border-radius: 999px; background: var(--sf-brand);
  color: #fff; font-size: 13px; font-weight: 700; text-decoration: none; }
/* THE ACCOUNT CONTROL (migration 0157). Colour comes from `currentColor` and the band's ink, like
   every other header widget — the glyph is stroked in currentColor so the merchant's Text colour
   reaches it through the ordinary cascade, with no widget-specific colour to keep in step.

   The sign-out FORM is laid out as part of the row rather than as a block: it exists because a
   session-ending control must be a POST, and a shopper should not be able to tell that from how it
   looks. */
.sf-acc { display: inline-flex; align-items: center; gap: 8px; flex: none; }
.sf-acc-a { display: inline-flex; align-items: center; gap: 6px; color: inherit; text-decoration: none;
  font-size: 13px; font-weight: 600; }
.sf-acc-a:hover { text-decoration: underline; }
.sf-acc-ic { display: inline-flex; width: 20px; height: 20px; }
.sf-acc-ic svg { width: 100%; height: 100%; }
.sf-acc-nm { max-width: 12ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-acc-out { display: inline-flex; margin: 0; }
/* `a` and `button` both, so the second door looks the same whichever element it has to be. */
a.sf-acc-b, button.sf-acc-b { padding: 0; border: 0; background: none; color: inherit; cursor: pointer;
  font: inherit; font-size: 12.5px; font-weight: 600; opacity: .75; text-decoration: underline; }
a.sf-acc-b:hover, button.sf-acc-b:hover { opacity: 1; }

/* The account PAGES (sign in, create, the account home). Deliberately plain: this is a form a
   shopper meets once, and the shop's own type and colour tokens carry it. */
.sf-account { max-width: 420px; margin: 0 auto; padding: 8px 0 40px; }
.sf-account h1 { font-size: 24px; margin: 0 0 16px; }
.sf-account-fields { display: flex; flex-direction: column; gap: 6px; }
.sf-account-label { font-size: 13px; font-weight: 600; margin-top: 8px; }
.sf-account-input { padding: 10px 12px; border: 1px solid var(--sf-border); border-radius: 8px;
  font: inherit; font-size: 15px; background: var(--sf-bg, #fff); color: inherit; }
.sf-account-hint { margin: 2px 0 0; font-size: 12.5px; color: var(--sf-muted); }
.sf-account-submit { margin-top: 16px; padding: 11px 18px; border: 0; border-radius: 999px;
  background: var(--sf-brand); color: #fff; font: inherit; font-weight: 700; cursor: pointer; }
.sf-account-error { margin: 0 0 14px; padding: 10px 12px; border-radius: 8px;
  background: var(--sf-warn-bg, #fdf1e7); color: var(--sf-warn-ink, #8a4b12); font-size: 14px; }
.sf-account-notice { margin: 0 0 16px; padding: 10px 12px; border-radius: 8px;
  background: var(--sf-accent); font-size: 14px; }
.sf-account-alt, .sf-account-guest { margin: 14px 0 0; font-size: 13.5px; color: var(--sf-muted); }
.sf-account-nav { list-style: none; margin: 0 0 20px; padding: 0; display: flex; flex-direction: column; gap: 8px; }

/* THE ORDER HISTORY. A list of orders is a list, so it stays a <ul> for the screen reader and loses
   its markers for everyone else — a bulleted receipt reads as an unstyled page, not as a list. */
.sf-orders { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.sf-order-row { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
  gap: 8px 16px; padding: 14px 16px; border: 1px solid var(--sf-border); border-radius: 10px; }
.sf-order-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.sf-order-no { font-weight: 700; text-decoration: none; color: inherit; }
.sf-order-no:hover { text-decoration: underline; }
.sf-order-status { font-size: 13.5px; color: var(--sf-muted); }
.sf-order-meta { display: flex; align-items: baseline; gap: 12px; }
.sf-order-date { font-size: 13px; color: var(--sf-muted); }
/* Digits in a column of prices line up, or the eye cannot compare them. */
.sf-order-total { font-size: 15px; font-variant-numeric: tabular-nums; }
.sf-order-refunded { font-size: 12.5px; color: var(--sf-muted); }
.sf-order-pager { display: flex; align-items: center; gap: 14px; margin-top: 18px; font-size: 13.5px; }
.sf-order-pager span { color: var(--sf-muted); }

.sf-account-empty { padding: 22px 20px; border: 1px dashed var(--sf-border); border-radius: 10px; }
.sf-account-empty p { margin: 0 0 10px; font-size: 14.5px; }
.sf-account-empty p:last-child { margin-bottom: 0; }

/* One order in full. */
.sf-order-back { margin: 0 0 14px; font-size: 13.5px; }
.sf-order-status-lead { margin: 0 0 4px; font-size: 16px; font-weight: 600; }
.sf-order-placed { margin: 0 0 22px; font-size: 13.5px; color: var(--sf-muted); }
.sf-order-block { margin: 0 0 26px; }
.sf-order-block h2 { font-size: 15px; margin: 0 0 10px; }
.sf-order-parcel { margin: 0 0 6px; font-size: 14px; }
.sf-order-lines { width: 100%; border-collapse: collapse; font-size: 14px; }
.sf-order-lines th, .sf-order-lines td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--sf-border); }
.sf-order-lines th:last-child, .sf-order-lines td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.sf-order-lines th:nth-child(2), .sf-order-lines td:nth-child(2) { text-align: right; width: 4em; }
.sf-order-totals { display: grid; grid-template-columns: 1fr auto; gap: 6px 20px; margin: 14px 0 0; font-size: 14px; }
.sf-order-totals dt { color: var(--sf-muted); }
.sf-order-totals dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.sf-order-totals .sf-order-grand { font-weight: 700; color: inherit; font-size: 15px; }
.sf-order-totals dt.sf-order-saving, .sf-order-totals dd.sf-order-saving { font-size: 12.5px; padding-left: 12px; }
.sf-order-address { margin: 0; font-size: 14px; }
.sf-order-refund-note { margin: 12px 0 0; padding: 10px 12px; border-radius: 8px; background: var(--sf-accent); font-size: 13.5px; }
.sf-account-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.sf-account-cards a { display: block; padding: 14px 16px; border: 1px solid var(--sf-border);
  border-radius: 10px; color: inherit; text-decoration: none; }
.sf-account-cards a:hover { border-color: var(--sf-brand); }
.sf-account-cards b { display: block; font-size: 14.5px; margin-bottom: 3px; }
.sf-account-cards span { font-size: 13.5px; color: var(--sf-muted); }
.sf-account-confirm { margin: 0 0 22px; }
.sf-sec-block { margin: 0 0 30px; padding-bottom: 26px; border-bottom: 1px solid var(--sf-border); }
.sf-sec-block:last-of-type { border-bottom: 0; }
.sf-sec-block h2 { font-size: 15px; margin: 0 0 8px; }
.sf-sec-block > p { margin: 0 0 12px; font-size: 14px; }
.sf-sec-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.sf-sec-row { display: flex; align-items: center; justify-content: space-between; gap: 16px;
  padding: 12px 14px; border: 1px solid var(--sf-border); border-radius: 10px; font-size: 14px; }
.sf-sec-row form { margin: 0; }
.sf-sec-row span { display: block; font-size: 13px; color: var(--sf-muted); }
.sf-sec-note { margin-top: 18px; }
.sf-account-export { margin: 30px 0 0; padding-top: 22px; border-top: 1px solid var(--sf-border); }
.sf-account-export h2 { font-size: 15px; margin: 0 0 6px; }
.sf-account-export p { margin: 0 0 12px; font-size: 13.5px; color: var(--sf-muted); }
/* THE PROVIDER BUTTONS. Full width and stacked: on a phone two side-by-side buttons are two
   half-width targets, and the shop with only one configured would get a lonely half-row. */
.sf-sso { display: flex; flex-direction: column; gap: 8px; margin: 0 0 4px; }
.sf-sso form { margin: 0; }
.sf-sso-btn { display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%;
  padding: 11px 16px; border: 1px solid var(--sf-border); border-radius: 999px;
  background: var(--sf-surface, #fff); color: inherit; font: inherit; font-size: 14.5px;
  font-weight: 600; cursor: pointer; }
.sf-sso-btn:hover { border-color: var(--sf-ink, #111); }
.sf-sso-mark { display: inline-flex; width: 18px; height: 18px; }
.sf-sso-mark svg { width: 100%; height: 100%; }
/* Apple's mark is a solid glyph in currentColor; Google's is its own four colours and must not be
   recoloured — brand guidelines, and a recoloured Google G reads as a phishing page. */
.sf-sso-apple .sf-sso-mark { color: inherit; }
.sf-sso-or { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: var(--sf-muted);
  font-size: 12.5px; text-transform: uppercase; letter-spacing: .06em; }
.sf-sso-or::before, .sf-sso-or::after { content: ""; flex: 1; height: 1px; background: var(--sf-border); }

.sf-account-static { margin: 0 0 6px; display: flex; flex-direction: column; gap: 3px; }

/* ── THE GUEST ORDER LOOKUP ──────────────────────────────────────────────────────────────────
   ⚠️ THIS PAGE HAD NO STYLES AT ALL. Every class its template used — `sf-lookup-form`,
   `sf-lookup-lines`, `sf-lookup-totals`, `sf-lookup-error` — was written and never defined, so a
   guest looking up their order got browser defaults: labels beside inputs on one line, a 150px
   text box, an unstyled table. It shipped that way because nothing renders it in the admin and no
   test asserts on how it looks.

   The ORDER itself now draws through `_order_detail.html.twig` and the `.sf-order-*` rules above,
   shared with the account page. What is left here is the page around it. The form reuses the
   account field rules for the same reason: two sets of near-identical inputs is how the two pages
   end up looking like different websites. */
.sf-lookup { max-width: 620px; margin: 0 auto; padding: 8px 0 44px; }
.sf-lookup-head { margin: 0 0 20px; }
.sf-lookup-head h1 { font-size: 24px; margin: 0; }
.sf-lookup-lede { margin: 8px 0 0; font-size: 14.5px; color: var(--sf-muted); }
/* The form sits in a panel; the RESULT does not — a result is the page's content, not an input to
   it, and boxing it would put a frame around the thing they came for. */
.sf-lookup-panel { padding: 26px 24px 24px; border: 1px solid var(--sf-border); border-radius: 12px; }
.sf-lookup-alt { margin: 18px 0 0; padding-top: 16px; border-top: 1px solid var(--sf-border);
  font-size: 13.5px; color: var(--sf-muted); }
.sf-lookup-after { margin: 6px 0 0; padding-top: 20px; border-top: 1px solid var(--sf-border); }
.sf-lookup-again { font-size: 14px; }
.sf-lookup-nudge { margin: 10px 0 0; font-size: 13.5px; color: var(--sf-muted); }
@media (max-width: 560px) {
  .sf-lookup-panel { padding: 20px 16px 18px; }
}
.sf-account-consent { margin: 18px 0 0; padding: 14px 16px; border: 1px solid var(--sf-border); border-radius: 10px; }
.sf-account-consent legend { padding: 0 6px; font-size: 13px; font-weight: 700; }
.sf-account-check { display: flex; align-items: flex-start; gap: 8px; font-size: 14px; margin: 0 0 8px; }
.sf-account-check:last-child { margin-bottom: 0; }
.sf-account-check input { margin-top: 2px; }

.sf-addresses { list-style: none; margin: 0 0 26px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.sf-address { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between;
  gap: 10px 16px; padding: 14px 16px; border: 1px solid var(--sf-border); border-radius: 10px; }
.sf-address-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; font-size: 14px; }
.sf-address-default { align-self: flex-start; padding: 1px 8px; border-radius: 999px;
  background: var(--sf-accent); font-size: 11.5px; font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; }
.sf-address-acts { display: flex; align-items: center; gap: 12px; }
.sf-address-acts form { margin: 0; }
.sf-address-new { margin-top: 4px; padding-top: 22px; border-top: 1px solid var(--sf-border); }
.sf-address-new h2 { font-size: 15px; margin: 0 0 4px; }

/* THE ACCOUNT SHELL — heading, side navigation, content. `sf-account` above is the narrow column
   the sign-in form sits in; `is-wide` widens it for the pages that list things, so the two share
   every other rule rather than owning two ideas of what an account page looks like. */
.sf-account-wide { max-width: 980px; }
.sf-account-head { margin: 0 0 18px; }
.sf-account-lede { margin: 6px 0 0; color: var(--sf-muted); font-size: 14.5px; }
.sf-account-body { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 32px; align-items: start; }
.sf-account-side ul { list-style: none; margin: 0 0 18px; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.sf-account-side a { display: block; padding: 8px 10px; border-radius: 8px; color: inherit;
  text-decoration: none; font-size: 14px; }
/* A LEFT RAIL, NOT A HIGHLIGHT BLOCK. Filling the current item with the brand tint made the one
   thing already obvious — which page you are on — the loudest object on a page whose actual content
   is a list of orders. A weight change plus a brand rule says it without competing. */
.sf-account-side a:hover { background: var(--sf-surface, rgba(0, 0, 0, .04)); }
.sf-account-side a.is-on { font-weight: 700; box-shadow: inset 2px 0 0 var(--sf-brand); border-radius: 0 8px 8px 0; }
.sf-account-out { margin: 0; }
.sf-acc-signout { padding: 8px 10px; border: 0; background: none; color: var(--sf-muted); cursor: pointer;
  font: inherit; font-size: 13.5px; text-decoration: underline; }
.sf-acc-signout:hover { color: inherit; }
/* One column on a phone: a 200px rail beside content is a 120px content column on a 375px screen. */
@media (max-width: 700px) {
  .sf-account-body { grid-template-columns: minmax(0, 1fr); gap: 20px; }
  .sf-account-side ul { flex-direction: row; flex-wrap: wrap; gap: 6px; }
}

.sf-ftr-text { font-size: 13.5px; color: var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-muted))); }
.sf-ftr-btn { align-self: start; padding: 7px 15px; border-radius: 999px; border: 1px solid var(--sf-border);
  color: inherit; font-size: 13px; font-weight: 600; text-decoration: none; }

/* A placement's own micro-widgets sit BESIDE the widget they are attached to, in the flow of the
   region — not above or below it, which is what a block's bands do, because a header region is a row
   and a block is a stack. */
.sf-mi-band-chrome { display: inline-flex; align-items: center; gap: 8px; }

/* The header search: the Shop page's search, at header size. `min-width: 0` on both the form and its
   input because a flex item's default minimum is its content, and without it the box refuses to
   shrink and pushes the basket off the right edge. */
.sf-hdr-search { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 0 1 260px; }
/* ── PC-10: the box's own room (owner ask, 2026-08-13) ────────────────────────────────────────
   The width var rides the placement scope (the wrapper is display:contents, so it inherits to
   the form) and lands as FLEX-BASIS, keeping shrink at 1 — the box always gives way before it
   squeezes the logo or the basket. The has-cat pair repeats the read because its fallback is a
   different number.

   ⚠️ ALL THREE RULES LIVE BEHIND THE DESKTOP GATE, and the first cut did not — adversarial
   review caught both halves before commit. Ungated, the basis pair beat the phone block's
   `flex: 1 1 100%` (:~1002) by specificity at EVERY width, so a styled search stopped taking
   its own full row on phones — including on shops styled long before this shipped. These are
   BAR measurements; the phone block owns ≤760.

   ⚠️ AND THE FILL RULE CARRIES NO `.sf-chr-slot >` PREFIX, deliberately: the wrapper exists
   only when something ELSE styles the placement, and `fill` alone emits no CSS — prefixed, the
   control worked in the builder preview (which forces the wrapper) and was dead on the shop,
   the module's signature defect. A class-only control must never depend on a wrapper it does
   not create (the nav_mode rule). */
@media (min-width: 761px) {
  .sf-chr-slot > .sf-hdr-search { flex-basis: var(--sf-chr-search-w, 260px); }
  .sf-chr-slot > .sf-hdr-search.has-cat { flex-basis: var(--sf-chr-search-w, 380px); }
  .sf-hdr-search.sf-search-fill { flex-grow: 1; }
}
.sf-hdr-search input { min-width: 0; width: 100%; padding: 7px 13px; border: 1px solid var(--sf-border);
  border-radius: 999px; font: inherit; font-size: 13.5px; background: var(--sf-surface); color: var(--sf-ink); }
/* ⚠️ THE DEFAULTS ARE THE OLD LITERALS, so a shop that sets neither colour is served exactly what it
   was: `--sf-ink` on the button and `--sf-bg` for the words. The two controls (gap #7) only ever
   REPLACE a fallback — nothing changes for a shop that has chosen nothing. */
.sf-hdr-search-go { flex: none; padding: 7px 14px; border: 0; border-radius: 999px;
  background: var(--sf-chr-go-bg, var(--sf-ink)); color: var(--sf-chr-go-ink, var(--sf-bg));
  font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
/* Icon-only: a square-ish target rather than a wide pill with a glyph adrift in it. 40px keeps the
   tap target at the size a thumb needs on a phone. */
.sf-hdr-search-go.is-icon { display: inline-flex; align-items: center; justify-content: center;
  width: 40px; height: 34px; padding: 0; }
.sf-hdr-search-go .sf-mi-glyph { width: 16px; height: 16px; }

/* ── THE MAGNIFIER SITS INSIDE THE FIELD, not beside it (owner ask, 2026-09-10) ────────────────
   The reference header is one rounded box with the glyph tucked into its right end; ours laid the
   input and the button out as flex SIBLINGS with a 6px gap and a pill radius each, so it read as
   two controls that happened to touch.

   ⚠️ THE FIELD STAYS THE VISUAL BOX. The obvious version — move the border and background onto the
   `<form>` — would have broken the design controls: a merchant's radius, width, background and
   border are all read one level in, on `.sf-hdr-search-q` and the category select beside it (see
   the block near :3490 that says so in as many words). Moving the box would have left every one of
   those settings addressing an element that no longer draws anything. So the field keeps the box
   and the button is lifted out of the flow and laid on top of it.

   ⚠️ ONLY WHEN THE BUTTON IS ICON-ONLY. A worded "Search" button belongs beside the field, not
   floating in it, and `.is-icon` is exactly the case that wants nesting. Keyed on `:has()` for the
   footer's reason: a browser without it keeps today's side-by-side pair — the old behaviour, not a
   broken one — and all three declarations sit behind the same test so it is all or nothing.

   The colour defaults change ONLY inside the field: a filled dark pill floating in a white box is
   not what nesting means, so an unset button is transparent with `--sf-ink` here. A merchant who
   has chosen `go_bg`/`go_ink` still gets exactly what they chose. */
.sf-hdr-search:has(> .sf-hdr-search-go.is-icon) { gap: 0; }
.sf-hdr-search:has(> .sf-hdr-search-go.is-icon) > .sf-hdr-search-q { padding-right: 46px; }
.sf-hdr-search:has(> .sf-hdr-search-go.is-icon) > .sf-hdr-search-go.is-icon {
  position: absolute; right: 5px; top: 50%; transform: translateY(-50%);
  width: 34px; height: 30px; border-radius: 999px;
  background: var(--sf-chr-go-bg, transparent);
  color: var(--sf-chr-go-ink, var(--sf-ink));
}

/* THE CATEGORY DROPDOWN, when the shop has switched one on. `flex: none` with a max-width so a shop
   whose categories are called "Disposable vapes and pods" does not hand the box two characters of
   room; the name truncates in the closed select and is whole in the open list, which is how a native
   select already behaves and is the reason this is one rather than a custom widget. A form with a
   dropdown needs more room overall, hence the wider basis on `.has-cat`. */
.sf-hdr-search.has-cat { flex: 0 1 380px; }
.sf-hdr-search-cat { flex: none; max-width: 130px; padding: 7px 9px; border: 1px solid var(--sf-border);
  border-radius: 999px; font: inherit; font-size: 13px; background: var(--sf-surface); color: var(--sf-ink);
  cursor: pointer; }

/* ── THE SUGGESTION PANEL ──────────────────────────────────────────────────────────────────────
   `position: relative` on the form so the panel hangs off the box rather than off the page; the form
   is inside the header's flex row, which already establishes the line the panel should start under.

   The panel is `hidden` in the markup and stays hidden until search.js has something to draw, so
   these rules describe something that does not exist on a shop with suggestions switched off — and
   on one that has them on, they never apply before the first answer arrives. There is no state in
   which an empty panel is drawn.

   `z-index` above the header's own stacking so the list is not clipped by a sticky header; `max-height`
   with `overflow-y: auto` so six suggestions plus the "see everything" row cannot run off a short
   phone in landscape. */
.sf-hdr-search { position: relative; }
.sf-hdr-sug { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 60;
  max-height: min(60vh, 380px); overflow-y: auto; padding: 5px; border: 1px solid var(--sf-border);
  border-radius: 14px; background: var(--sf-surface); box-shadow: 0 14px 38px rgba(0, 0, 0, .16); }
.sf-hdr-sug[hidden] { display: none; }

.sf-sug-row { display: flex; align-items: baseline; gap: 8px; padding: 8px 10px; border-radius: 10px;
  color: var(--sf-ink); text-decoration: none; font-size: 13.5px; }
/* One highlight class for BOTH the pointer and the keyboard. `is-on` is what search.js sets as the
   arrow keys walk the list, and hover reuses it rather than declaring a second appearance — two
   looks for "this is the one you are about to choose" is how a list ends up highlighting two rows. */
.sf-sug-row:hover, .sf-sug-row.is-on { background: var(--sf-bg-soft, rgba(0, 0, 0, .05)); }
.sf-sug-row.is-on { outline: 2px solid var(--sf-brand); outline-offset: -2px; }
.sf-sug-brand { flex: none; font-size: 11.5px; text-transform: uppercase; letter-spacing: .04em;
  color: var(--sf-muted); }
.sf-sug-title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-sug-price { flex: none; font-weight: 700; }
/* The last row is the way out to the real results page, so it reads as an action rather than as a
   seventh product. */
.sf-sug-all { justify-content: center; margin-top: 3px; border-top: 1px solid var(--sf-border);
  border-radius: 0 0 10px 10px; font-weight: 700; color: var(--sf-brand); }
.sf-sug-none { margin: 0; padding: 10px; font-size: 13px; color: var(--sf-muted); }

/* On a phone the three regions stack instead of competing for one 375px row, and the header stops
   claiming a fixed height it can no longer honour. The search keeps its button: this form has more
   than one control, so Enter is not a guaranteed submit and hiding it would strand the shopper.

   ⚠️ 760, NOT 640 (header plan slice 8). THE PHONE BOUNDARY IS THE ONE MERCHANTS ALREADY SET AT.
   `.sf-only-desktop` — the per-device visibility control a merchant ticks on a widget — hides at
   760. This stacked at 640, so between 641 and 760 a shop drew a DESKTOP header grid, at a fixed
   height, with its desktop-only widgets already gone: a half-mobile header, in a 120px band that
   covers most small tablets and every landscape phone. Two merchant-facing features both called
   "phone" disagreeing about what a phone is. */
@media (max-width: 760px) {
  .sf-header-in.sf-hdr { grid-template-columns: 1fr; height: auto; gap: 10px; padding-top: 12px; padding-bottom: 12px; }
  .sf-hdr-r { justify-content: center; gap: 14px; }
  .sf-hdr-search, .sf-hdr-search.has-cat { flex: 1 1 100%; }
  /* The category name gets a third of a phone's width and no more — the box has to stay usable. */
  .sf-hdr-search-cat { max-width: 33%; }
  .sf-hdr-row2 { gap: 14px; padding-bottom: 12px; }
}

/* ── THE LOGO, THE BASKET, AND FOUR LOGOS FOR ONE SHOP ─────────────────────────────────────────
   Owner: logo picture/size/position/text, separate logos for desktop, mobile, dark and sticky, and
   basket icon customisation.

   NOTHING BELOW IS A MERCHANT-TYPED VALUE — four named heights, three positions, three type steps,
   all classes the server picked from closed lists. That is why the whole feature needs no custom
   property and no CSSOM call under `default-src 'self'` with no `style-src`.

   COST: this block is ~1.8 KB raw / ~570 B gzipped, and it is the ONLY thing a shop that never opens
   these controls pays — in the page itself, nothing: an unset variant emits no tag and an untouched
   control emits no class, so a shop with one logo still renders `class="sf-logo"`. */
.sf-brand-centre { margin-inline: auto; }
.sf-brand-end { margin-inline-start: auto; }
.sf-logo-s { max-height: 26px; }
.sf-logo-l { max-height: 52px; }
.sf-logo-xl { max-height: 68px; }

/* THE VARIANT STACK. Emitted only when there is more than one picture. Every variant sits in the
   same grid cell, so the header is exactly one logo tall whichever one is showing and swapping never
   changes the height of a bar that is on every page. A shop with one logo has no wrapper at all. */
.sf-logo-stack { display: inline-grid; }
.sf-logo-stack > .sf-logo { grid-area: 1 / 1; }
/* Off until their condition holds. The BASE is never hidden by a rule that has not first checked the
   replacement exists (`:has`), which is what makes "an unset variant falls back" a property of the
   cascade rather than of the template remembering to be careful. */
.sf-logo-mobile, .sf-logo-dark, .sf-logo-sticky { display: none; }

/* ⚠️ THE SAME 760, AND FOR THE SAME REASON. A merchant who uploads a MOBILE LOGO and ticks a widget
   "phone only" is describing one device; the logo swapping at 640 while the visibility rule fired at
   760 meant their mobile logo appeared 120px later than their mobile layout did.
   LogoBreakpointTest pins these two to each other. */
@media (max-width: 760px) {
  .sf-logo-stack:has(> .sf-logo-mobile) > .sf-logo-main { display: none; }
  .sf-logo-stack > .sf-logo-mobile { display: block; }
  /* An extra-large logo is extra large for a desktop header. On a 375px row it is most of the row. */
  .sf-logo-xl { max-height: 48px; }
}

/* Dark beats mobile: a logo drawn for a light background on a dark screen is unreadable, while a
   desktop-shaped logo on a phone is merely bigger than ideal. */
@media (prefers-color-scheme: dark) {
  .sf-logo-stack:has(> .sf-logo-dark) > .sf-logo-main,
  .sf-logo-stack:has(> .sf-logo-dark) > .sf-logo-mobile { display: none; }
  .sf-logo-stack > .sf-logo-dark { display: block; }
}

/* THE STICKY LOGO — the one variant with no CSS condition of its own, because "the header is
   currently stuck" is not a state a selector can ask about: there is no `:stuck`, and the sentinel
   trick needs an IntersectionObserver. A SCROLL-DRIVEN ANIMATION is CSS, costs no JavaScript, and is
   behind `@supports` for engines that lack it.

   THE FALLBACK IS THE POINT: without support the sticky logo keeps `display: none` from the rule
   above and the main logo never swaps — the merchant's other logo is still on the page. The template
   only emits this variant when the header actually stays on screen (ChromeWidgets::logoImages). */
@supports (animation-timeline: scroll()) {
  .sf-logo-stack > .sf-logo-sticky {
    display: block; animation: sf-logo-in linear both;
    animation-timeline: scroll(); animation-range: 0 90px;
  }
  .sf-logo-stack:has(> .sf-logo-sticky) > .sf-logo:not(.sf-logo-sticky) {
    animation: sf-logo-out linear both;
    animation-timeline: scroll(); animation-range: 0 90px;
  }
}
@keyframes sf-logo-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes sf-logo-out { from { opacity: 1; } to { opacity: 0; } }

/* THE HEADER THAT GETS SHORTER AS YOU SCROLL — "As the page scrolls" in the inspector.

   Same technique as the sticky logo directly above, and for the same reason: "the header is
   currently stuck" is not a state a selector can ask about, but SCROLL POSITION is something a
   scroll-driven animation reads for free. No JavaScript, no scroll listener, nothing for the CSP
   to drop, and no layout thrash on a long page.

   THE FALLBACK IS AGAIN THE POINT: without `animation-timeline` the header simply keeps the fixed
   `--sf-header-h` from `.sf-header-in` and behaves exactly as it does today. Nothing is lost, and
   nothing about the page depends on the animation having run.

   `height` and not `padding`, because `.sf-header-in` is a flex row centred on a fixed height —
   animating the height is what actually moves the bar, and `align-items: center` keeps the logo,
   nav and basket centred the whole way down. The template only emits `sf-hdr-shrink` when the
   header is ALSO sticky. */
@supports (animation-timeline: scroll()) {
  @media (min-width: 641px) {
    .sf-hdr-shrink .sf-header-in {
      animation: sf-hdr-shrink linear both;
      animation-timeline: scroll();
      animation-range: 0 120px;
    }
  }
}
@keyframes sf-hdr-shrink {
  from { height: var(--sf-header-h); }
  to { height: calc(var(--sf-header-h) * 0.68); }
}
/* Below 641px the header is already `height: auto` and stacked (see the phone rules further down),
   so there is no fixed height to shrink and the animation is scoped away above rather than fighting
   it. A shopper who asked for less motion keeps the header they had. */
@media (prefers-reduced-motion: reduce) {
  .sf-hdr-shrink .sf-header-in { animation: none; }
}
/* A shopper who has asked for less motion gets the logo they had. A crossfade is small, but it is
   still something moving that they did not ask for, and the honest fallback is already built. */
@media (prefers-reduced-motion: reduce) {
  .sf-logo-stack > .sf-logo-sticky { display: none; animation: none; }
  .sf-logo-stack > .sf-logo:not(.sf-logo-sticky) { animation: none; }
}

/* THE SHOP NAME AS WORDS. `.sf-brand` already sets the family, the weight and the tracking; these
   only exist for a merchant who has asked for something other than the theme's answer, and the span
   itself is not emitted until one of them has. */
.sf-bt-s { font-size: 15px; }
.sf-bt-l { font-size: 24px; }
.sf-bt-xl { font-size: 30px; }
.sf-btw-normal { font-weight: 400; }
.sf-btw-bold { font-weight: 700; }
.sf-btw-black { font-weight: 900; }
.sf-btc-upper { text-transform: uppercase; }
.sf-btc-lower { text-transform: lowercase; }

/* THE BASKET WITH A GLYPH. Scoped to `:has()` on purpose: `.sf-cart-link` is also the classic
   header's basket on three trading shops, and making it a flex container unconditionally would
   change a line nobody asked to have changed. With no icon chosen this rule does not exist. */
/* ⚠️ THE GLYPH IS NOT ALWAYS A DIRECT `.sf-mi-glyph` ANY MORE. `count_at: on_icon` wraps it in
   `.sf-cart-ig` so the badge has something to anchor to, and a merchant's own picture arrives as
   `.sf-cart-img` — either of which left this selector unmatched, the link fell back to
   `display: block`, and the icon and the words sat flush against each other with no gap at all.
   Measured against the reference cart, which has 10px between them. */
.sf-cart-link:has(> .sf-mi-glyph),
.sf-cart-link:has(> .sf-cart-ig),
.sf-cart-link:has(> .sf-cart-img),
.sf-cart-link:has(> .sf-ic) { display: inline-flex; align-items: center; gap: var(--sf-chr-gap, 6px); }

.sf-ftr-top { display: grid; gap: 10px; }
/* ── PC-8: THE TOP ROW'S COLUMNS (owner ask, 2026-08-13) ──────────────────────────────────────
   The container is ALREADY a grid, so a column count is one template-columns rule per choice —
   no display change, no new wrapper. Desktop-side only: a phone always stacks, which is why the
   rules live behind min-width 761 (the shop's one boundary) rather than being undone below it.
   `minmax(0, 1fr)`, never bare `1fr`: a long word in the links column would otherwise widen its
   track and steal the others' space. `align-items: start` because columns of unequal depth read
   as columns, not as a stretched row. */
@media (min-width: 761px) {
  .sf-ftr-top.sf-ftr-c-two { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 40px; align-items: start; overflow-wrap: anywhere; }
  .sf-ftr-top.sf-ftr-c-three { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 40px; align-items: start; overflow-wrap: anywhere; }
  .sf-ftr-top.sf-ftr-c-four { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 32px; align-items: start; overflow-wrap: anywhere; }
  /* One placement per cell (the template wraps them when columns are on) — without the cell, a
     widget's Extras band was a SIBLING grid item and marched into the next column. The cell is
     its own small grid so the widget-to-band rhythm matches the stacked footer's. `overflow-wrap`
     above because minmax(0,1fr) tracks are content-independent: an email address or a long URL
     must wrap inside its column, not paint over the neighbour. */
  .sf-ftr-top > .sf-ftr-cell { display: grid; gap: 10px; align-content: start; }
  .sf-ftr-cell > * { margin: 0; }
}
.sf-ftr-top > *, .sf-ftr-bar > * { margin: 0; }
.sf-ftr-note { margin: 0; }
/* A bar, not a stack: separated by a rule, spread across the width, and drawn only when it holds
   something — an empty bottom bar is a horizontal line under nothing. */
.sf-ftr-bar { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 18px;
  margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--sf-border); }
.sf-ftr-centre { text-align: center; }
/* ══ THE FOOTER'S COLUMN ROW, WHEN IT CARRIES A MENU (2026-09-10) ═══════════════════════════════
   Owner: replicate vapeandgo.co.uk's footer.

   ⚠️⚠️ THE PROBLEM WAS NESTING, NOT STYLING. `.sf-ftr-top` is a grid of CELLS, and the whole footer
   menu — four titled columns — is ONE of them. So a footer arranged as logo · menu · contact · legal
   drew four cells of 326px with the four nav columns squeezed two-abreast inside the second, while
   the reference lays logo, four columns and contact side by side as six siblings (200px + 5×219px).

   The fix is to stop making the menu compete for one cell: the row becomes flex, the logo and the
   contact block take their natural width, and the MENU takes the rest and spends it on its own
   columns. That is why this is keyed on `:has(.sf-footer-menu)` — a footer WITHOUT a menu keeps the
   grid it has always had, so no shop that has not built a footer menu moves at all.

   ⚠️ `:has()` rather than a class, for the reason the filter rail uses it: the fact being tested is
   "does this footer contain a menu", which no server-side class records, and a browser without
   `:has()` simply keeps the previous grid — the old behaviour, not a broken one. */
@media (min-width: 761px) {
  .sf-ftr-top:has(.sf-footer-menu) {
    display: flex; flex-wrap: wrap; align-items: flex-start; gap: 24px 32px;
    /* The cells' own widths are set below; the grid template must not also have an opinion. */
    grid-template-columns: none;
  }
  /* The mark, at the row's head — 200px is the reference's own logo column. */
  .sf-ftr-top:has(.sf-footer-menu) > .sf-ftr-cell:has(.sf-brand) { flex: 0 0 200px; }
  /* ⚠️ THE MENU IS WEIGHTED BY HOW MANY COLUMNS IT HOLDS, so every visual column comes out the
     same width (owner: "footer content and alignment not correct", 2026-09-10).

     It used to take `1 1 420px` — one share, like the single-column cells beside it — and then
     spend that one share on FOUR columns. Measured: the menu cell got 472px for four columns of
     ~118px while contact, legal and newsletter each got 200px to themselves. Nothing was
     misaligned in the CSS sense; the row was simply handing a quarter of the space to four times
     the content, so the reader saw four cramped columns and three roomy ones.

     `flex: 4 1 0` is four shares for four columns. With every non-logo cell on `flex-basis: 0` the
     bases total 200px against a 1400px row, so nothing can wrap either — which retires the
     240→200 threshold nudge that was here before, and the sixth-cell cliff it warned about.

     ⚠️ The 4 is the SAME hardcoded four as the grid-template-columns rule below, not a new
     assumption. If that count ever becomes a per-shop setting, both must read it.

     ⚠️ NOT `display: contents` on the cell to make the columns real siblings, which was the other
     way to do this: it would strip the box from the `<nav>` and `<ul>`, and list semantics with it
     in enough browsers to matter. A footer menu is exactly where a screen-reader user expects "list,
     4 items". `min-width: 0` because a flex item defaults to `auto` and would otherwise refuse to
     shrink below its longest link — the trap the admin filter rail hit, one template away. */
  /* ⚠️ AND THE BASIS IS THE MENU'S OWN INTERNAL GAPS, which is what makes the columns EXACTLY equal
     rather than nearly. Four shares alone left the menu's columns at 132px beside 153px cells: the
     cell spends 3 x 28px on the gaps between its four columns before any of the share reaches the
     text. Handing it those 84px as flex-basis, THEN dividing the remainder seven ways, lands every
     visual column on the same width — measured 141px across all seven at 1400px of row.
     The 84px is `3 * 28px` and the 28px is the column-gap declared below; the two move together. */
  .sf-ftr-top:has(.sf-footer-menu) > .sf-ftr-cell:has(.sf-footer-menu) { flex: 4 1 84px; min-width: 0; }
  /* Contact and business details sit as their own column, like the reference's sixth — ONE share
     each, against the menu's four.
     ⚠️ `flex-basis: 0`, not a px threshold. The previous fix here nudged the basis 240 → 200 so a
     fifth cell stopped wrapping onto its own row, and said in as many words that it was a
     threshold rather than an answer: a sixth cell wrapped again. Sizing by SHARE instead of basis
     removes the cliff — the bases now total 200px (the logo alone) against a 1400px row, so no
     count of cells can overflow it. They get narrower together, which is what a column does. */
  .sf-ftr-top:has(.sf-footer-menu) > .sf-ftr-cell:not(:has(.sf-footer-menu)):not(:has(.sf-brand)) {
    flex: 1 1 0; min-width: 0; }

  /* Four titled columns across, not two. `auto-fit`/`minmax(150px,…)` was fitting two into the one
     cell it used to have; with the whole remainder to work in, the count is the thing to state. */
  .sf-ftr-top:has(.sf-footer-menu) .sf-footer-menu.sf-footer-columns > .sf-nav-l1 {
    grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px 28px; }
}

/* ── The type, measured off the reference ──────────────────────────────────────────────────────
   Column title 18px/600 with 20px under it; links 14px on a 24px line. The COLOUR is a variable
   defaulting to what the footer already draws, so a shop that has set nothing keeps its footer
   exactly as it was and one that sets an accent gets the reference's bright heading. */
.sf-footer-menu { --sf-ftr-head: var(--sf-band-ink, var(--sf-band-ink-auto, inherit)); }
.sf-ftr-top:has(.sf-footer-menu) .sf-footer-menu.sf-footer-columns .sf-nav-l1 > .sf-nav-item > .sf-nav-link {
  font-size: 18px; font-weight: 600; margin-bottom: 14px; color: var(--sf-ftr-head); }
.sf-ftr-top:has(.sf-footer-menu) .sf-footer-menu .sf-nav-panel { gap: 2px; }
.sf-ftr-top:has(.sf-footer-menu) .sf-footer-menu .sf-nav-panel .sf-nav-link { font-size: 14px; line-height: 24px; }

/* ── The bottom bar: copyright · payments · social, spread ─────────────────────────────────────
   The reference sets these three apart with space-between and centres them on one 77px line. Ours
   ran them left-to-right in a row, so the payment marks sat against the copyright line. Again keyed
   on having something to spread, so a one-item bar is untouched. */
@media (min-width: 761px) {
  .sf-ftr-bar { justify-content: space-between; align-items: center; gap: 12px 24px; }
}
/* ⚠️ ON A PHONE THE BAR STACKS AND REVERSES — social first, then payments, then the copyright line,
   which is the reference's own order (measured: order 1, 2, 3 against DOM order copyright, payments,
   social). The copyright is the least useful line on a small screen and the reference puts it last;
   `column-reverse` says that in one word and keeps the DOM order the desktop bar needs. */
@media (max-width: 760px) {
  .sf-ftr-bar { flex-direction: column-reverse; align-items: flex-start; gap: 16px; }
}

.sf-ftr-centre .sf-ftr-top { justify-items: center; }
.sf-ftr-centre .sf-ftr-bar, .sf-ftr-centre .sf-footer-menu, .sf-ftr-centre .sf-footer-nav { justify-content: center; }


/* ── Listing filters (0072) ─────────────────────────────────────────────────────────────────── */
.sf-listing { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 28px; align-items: start; }
.sf-listing-main { min-width: 0; }
@media (max-width: 820px) {
  .sf-listing { grid-template-columns: 1fr; gap: 18px; }
  /* On a phone the filters go BELOW the products: a shopper came to see stock, not a form.
     ⚠️ AND THE RAIL DROPS ITS DESKTOP HEIGHT BUDGET WITH ITS STICKINESS. Stacked in the flow it is
     no longer pinned, so a cap plus a private scrollbar would be a small scrolling box inside an
     already-scrolling page — worse than the full-height list it replaced. The two belong in one
     block: they are one decision, and split apart the next person changes one of them. */
  .sf-filters { order: 2; position: static; max-height: none; overflow-y: visible; }
}

/* ── THE FILTER RAIL ───────────────────────────────────────────────────────────────────────────

   Reads the WHOLE header, not the bar — see `--sf-header-total` at the top of this file. It is
   the one rule here that is positioning itself below everything the header draws, and it lives
   inside <main>, where nothing the chrome layer writes can reach it.

   ⚠️⚠️ THREE MEASURED DEFECTS FIXED HERE (owner, 2026-09-07: "this filter box layout issue also
   this page scroling filter scrolling"). All three were invisible in a narrow pane and obvious at
   1280×900, which is why they survived.

   (1) EVERY CHILD OVERFLOWED THE BOX BY 25px. The rail is a 240px grid with 16px padding, so its
       content box is 208px — and all 134 descendants measured 265px, spilling brand names and facet
       counts out over the product grid. A grid item defaults to `min-width: auto`, which means it
       REFUSES to shrink below its content's intrinsic minimum: one long brand ("Vampire Vape
       Juice" + a count) sets the width for the whole rail. `minmax(0, 1fr)` is what lets a grid
       track actually be as narrow as it says it is. Same trap as the admin's `.pd-g2`/`.pd-g3`.

   (2) THE RAIL WAS TALLER THAN THE SPACE IT STICKS IN, WITH NO WAY TO SCROLL IT. Measured 888px
       tall against 703px of available viewport — the bottom 185px, INCLUDING the "Show N products"
       button, was permanently unreachable. Sticky positioning does not scroll a thing that is
       taller than its viewport; it just pins the top and lets the rest hang off the bottom. So the
       rail gets its own height budget and its own scrollbar.

   (3) IT OFFSET ITSELF BY A STICKY HEADER THAT IS NOT STICKY. `--sf-header-total` is the header's
       HEIGHT; what a sticky offset needs is the height the header still OCCUPIES once the page has
       scrolled — which is zero when the merchant has turned stickiness off (`.sf-header.is-static`,
       `chrome.sticky = false`). This shop is one of those, so the rail pinned itself 197px down the
       viewport for a header that had already scrolled away, wasting the very space that made (2)
       bite. `--sf-header-sticky` is that distinction.

   `:has()` rather than a body class: the switch lives on <header> and the rail lives in <main>, so
   nothing else can see it without a template change — and a browser without `:has()` simply keeps
   the old offset, which is today's behaviour rather than a broken one. */
:root { --sf-header-sticky: var(--sf-header-total); }
body:has(.sf-header.is-static) { --sf-header-sticky: 0px; }

.sf-filters { position: sticky; top: calc(var(--sf-header-sticky) + 12px);
  display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px;
  /* The budget: everything the pinned header still occupies, plus the 12px above and below. */
  max-height: calc(100vh - var(--sf-header-sticky) - 24px);
  overflow-y: auto;
  /* ⚠️ Without this, reaching the end of the rail's scroll hands the gesture to the PAGE — the
     shopper scrolls the filters, hits the bottom, and the whole listing lurches. */
  overscroll-behavior: contain;
  padding: 16px; background: var(--sf-surface); border: 1px solid var(--sf-border); border-radius: var(--sf-radius); }
/* ⚠️ A grid ITEM needs this too, not just the track: `minmax(0, 1fr)` sizes the column, and this is
   what lets the item inside it shrink to that column instead of to its own longest word. */
.sf-filters > * { min-width: 0; }
.sf-filters-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.sf-filters-h { margin: 0; font-size: 16px; font-weight: var(--sf-heading-weight); letter-spacing: var(--sf-heading-tracking); }
.sf-filters-count { font-size: 12px; color: var(--sf-muted); font-variant-numeric: tabular-nums; }

.sf-filters-active { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.sf-chip { font-size: 11.5px; padding: 3px 8px; border-radius: 999px;
  background: var(--sf-accent); color: var(--sf-ink); }
.sf-chip-clear { font-size: 11.5px; font-weight: 600; color: var(--sf-brand); }
.sf-chip-clear:hover { text-decoration: underline; }

.sf-facet { border: none; margin: 0; padding: 0; }
.sf-facet-h { padding: 0; margin: 0 0 7px; font-size: 13px; font-weight: 700; }
/* ⚠️ `minmax(0, 1fr)` AND `min-width: 0`, ONE LEVEL DOWN FROM `.sf-filters`, FOR THE SAME REASON.
   `.sf-facet-label` below already asks for an ellipsis — min-width:0, overflow:hidden, nowrap — and
   it never fired, because the ROW it lives in was a grid item at `min-width: auto` and so refused to
   shrink below its own longest brand. The label had no width to be too long for. Measured: 28 rows
   at 265px inside a 206px track, spilling brand names and counts across the product grid. */
.sf-facet-options { display: grid; grid-template-columns: minmax(0, 1fr); gap: 5px; max-height: 240px; overflow-y: auto; }
.sf-facet-opt { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; min-width: 0; }
.sf-facet-opt input { width: 15px; height: 15px; flex: none; accent-color: var(--sf-brand); }
.sf-facet-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-facet-n { flex: none; font-size: 11.5px; color: var(--sf-muted); font-variant-numeric: tabular-nums; }

.sf-price-row { display: flex; gap: 8px; }
.sf-price-field { flex: 1 1 0; display: grid; gap: 3px; font-size: 11.5px; color: var(--sf-muted); }
.sf-price-field input { width: 100%; border: 1px solid var(--sf-border); border-radius: var(--sf-radius);
  padding: 6px 8px; font: inherit; font-size: 13px; background: var(--sf-surface); color: var(--sf-ink); }
.sf-filters-apply { width: 100%; text-align: center; }


/* ── Navigation menus (0073) ────────────────────────────────────────────────────────────────── */
.sf-nav-left { margin-right: auto; padding-left: 22px; }
.sf-nav-center { position: absolute; left: 50%; transform: translateX(-50%); }
.sf-nav.is-caps { text-transform: uppercase; letter-spacing: .04em; font-size: 13px; }
.sf-nav.is-bold { font-weight: 700; }
.sf-header-in { position: relative; }

/* ── THE NAVIGATION TREE (0098) ────────────────────────────────────────────────────────────────
   `_menu_tree.html.twig` renders one `ul > li` tree for the header, the footer, and both arranged
   layouts. These rules replace `.sf-nav-group` / `.sf-nav-drop`, which were a hover-only panel: no
   `aria-expanded` for a screen reader to read, and NO WAY TO OPEN IT ON A PHONE, because the parent
   was a real link and the first tap navigated.

   ── TWO STATES, AND THE NO-SCRIPT ONE IS EXACTLY WHAT SHIPPED BEFORE ──────────────────────────
   With nav.js blocked, the `:hover` / `:focus-within` rules below open a panel precisely as the old
   ones did — nothing regresses. When nav.js runs it puts `sf-nav-js` on <html> BEFORE binding
   anything, and from then on `aria-expanded` is the only thing that opens a panel. There is no
   flash, because the panel is closed in both states. */
.sf-nav-tree { display: contents; }
/* The mega panel is anchored to the NAV, not to its own item: a wide panel hanging off a link near
   the right edge of a right-aligned nav is clipped by the viewport. `position: static` on the item
   hands the containing block to `.sf-nav` so `right: 0` means the end of the nav row. */
.sf-nav { position: relative; }
.sf-nav-item.is-mega { position: static; }
.sf-nav-l1 { display: flex; align-items: center; gap: 22px; list-style: none; margin: 0; padding: 0; }
.sf-nav-l2, .sf-nav-l3 { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
.sf-nav-item { position: relative; display: inline-flex; align-items: center; gap: 3px; }
.sf-nav-link { display: inline-flex; align-items: center; gap: 6px; }
.sf-nav-link.is-plain { cursor: default; }
.sf-nav-lab { display: inline-block; }

/* ── A HIGHLIGHT TAG ON A NAV LINK (0099) ──────────────────────────────────────────────────────
   Five named styles and no sixth, because each one is a class and the list is closed on purpose —
   an open colour field here is how a header ends up with six competing reds, and under
   `default-src 'self'` a per-link colour could not be a style attribute anyway.

   The colours are NOT new: sale/new/offer are the same hexes the merchandising badges already
   paint (`.badge-sw-1/2/3` above). One red in the shop, not two. `brand` follows the store's own
   token, which is why it is the default a merchant lands on.

   NO text-transform. The badge draws exactly what was typed — see `_menu_tree.html.twig` for why
   (screen readers spell some all-caps tokens, and it is the merchant's word). `white-space: nowrap`
   because the cap already guarantees it is short, and a badge that broke across two lines would
   push the header row taller on the one element every page draws. */
.sf-nav-badge { display: inline-block; flex: none; padding: 1px 7px; border-radius: 999px;
  font-size: 10.5px; font-weight: 700; line-height: 1.55; letter-spacing: .02em;
  white-space: nowrap; vertical-align: middle; }
.sf-nav-badge-brand { background: var(--sf-brand); color: var(--sf-brand-ink); }
.sf-nav-badge-sale  { background: #D64545; color: #fff; }
.sf-nav-badge-new   { background: #1F8A5B; color: #fff; }
.sf-nav-badge-offer { background: #E08600; color: #fff; }
/* The quiet one is an OUTLINE, not a fifth fill: a shop that wants a marker without a shout has no
   other way to ask for it, and a fifth solid colour would be the thing this list exists to refuse. */
.sf-nav-badge-quiet { background: none; color: var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-muted)));
  box-shadow: inset 0 0 0 1px var(--sf-border); }
/* Inside a drop-down the link is a full-width block rather than the inline-flex row it is up top, so
   the badge needs its own gap. `margin` and not `float`: a float is IGNORED on a flex item, and the
   mobile drawer makes this one a flex item — the rule would have worked on a laptop, done nothing on
   a phone, and looked deliberate in both. One spacing rule that behaves the same everywhere. */
.sf-nav-panel .sf-nav-link .sf-nav-badge { margin-left: 8px; }

/* The toggle is a SEPARATE control beside the link, never the link itself — the label navigates and
   the chevron discloses, so the first tap does the right thing on every device with no sniffing. */
.sf-nav-toggle { display: inline-flex; align-items: center; justify-content: center; padding: 3px;
  border: 0; background: none; color: inherit; cursor: pointer; line-height: 0; border-radius: 6px; }
/* The toggle's hover lives with the nav link's, up beside `.sf-nav`. It was duplicated here, and
   being LATER in source order at equal specificity this copy silently won — so fixing the band bug
   in one place only would have left the chevron openers still vanishing. */
.sf-nav-chev { transition: transform .16s ease; }
.sf-nav-toggle[aria-expanded="true"] .sf-nav-chev { transform: rotate(180deg); }

/* ⚠️ A SURFACE THAT PAINTS ITS OWN GROUND MUST CARRY ITS OWN INK.
 *
 * This panel paints `--sf-bg` but never set a colour, so it inherited the HEADER BAND's ink. On a
 * shop whose header is painted (vapeandgo: band ink #ffffff) every link in the dropdown was white
 * on the panel's white — contrast 1.00, twelve invisible brand names in an apparently empty box.
 *
 * The same root cause as the nav-link hover fixed in c75d68e2: band ink reaching a surface that is
 * NOT the band. The rule is the general one — pair the ink with the ground in the SAME rule that
 * paints it, so the two can never drift apart. `.sf-mega` panels were unaffected only because they
 * happened to set a colour of their own; that was luck, not design.
 *
 * The footer's own override sets `background: none`, and now restores `color: inherit` with it, so a
 * panel that gives up its ground gives up its ink too and goes back to the footer band's. */
.sf-nav-panel { position: absolute; top: 100%; left: 0; z-index: 20; display: none; min-width: 190px;
  padding: 6px; background: var(--sf-surface); color: var(--sf-ink); border: 1px solid var(--sf-border);
  border-radius: var(--sf-radius); box-shadow: var(--sf-shadow);
  /* Never wider than the window, whatever the longest brand name is. */
  max-width: calc(100vw - 24px); }
/* ⚠️ AND IT MUST NOT OPEN OFF THE EDGE OF THE SCREEN. Anchored to its item's LEFT edge, the panel
   belonging to a nav item near the end of the row spills past the viewport — "Brands" is the last
   item on vapeandgo and opened at x=1277 with width 190 in a 1440 window, so a third of it was
   unreachable. Flipping the trailing items to their RIGHT edge is a positional heuristic rather
   than a measurement, and it is deliberate: it is deterministic, costs no JavaScript, and nav.js
   has no layout pass to hang a real collision test on. Two items rather than one so the fix does
   not depend on "Brands" staying last. */
/* ⚠️ `:not(.sf-mega-full)` — a full-width mega is ALREADY pinned to both edges of the nav, and this
   rule was taking its `left` away again. Written when every panel was a narrow dropdown anchored to
   its item's left edge, where flipping the trailing ones was the only way to keep them on screen. A
   full-width mega has no left edge to flip: `left: 0; right: 0` IS its width. Stripping the left
   made the last item's panel 829px starting at 391 while its seven siblings spanned 219–1221, which
   the owner saw as the menu "not directly under" its link. */
.sf-nav-l1 > .sf-nav-item:nth-last-child(-n+2) > .sf-nav-panel:not(.sf-mega-full) { left: auto; right: 0; }
.sf-nav-panel .sf-nav-item { display: flex; justify-content: space-between; width: 100%; }
.sf-nav-panel .sf-nav-link { display: block; flex: 1; padding: 7px 10px;
  border-radius: calc(var(--sf-radius) / 2); font-size: 14px; }
.sf-nav-panel .sf-nav-link:hover { background: var(--sf-surface); }
/* Level 3 hangs to the SIDE of its level-2 row, not below it, so a three-level menu is one panel
   with a wing rather than a popup on a popup. In a mega panel it is a column instead — see below. */
.sf-nav-panel .sf-nav-panel { top: -6px; left: 100%; }

/* No script: today's behaviour, unchanged. ⚠️ Scoped to `.sf-nav` for the reason below — a footer
   panel is permanently open and must not be opened OR closed by the pointer. */
.sf-nav .sf-nav-item:hover > .sf-nav-panel,
.sf-nav .sf-nav-item:focus-within > .sf-nav-panel { display: grid; }
/* Script ran: the attribute is the state, and the pointer is not.
 *
 * ⚠️ SCOPED TO `.sf-nav` — WITHOUT IT THIS RULE MADE THE FOOTER BLINK.
 *
 * `.sf-nav-item` is not unique to the header: the footer menu builds from the same partial, so all
 * 45 of them split 24 in `.sf-nav` and 21 in `.sf-footer-menu`. A FOOTER panel is meant to be
 * permanently open — `.sf-footer-menu .sf-nav-panel` sets `position: static; display: grid` — but
 * that is (0,2,0) and this rule is (0,4,0), so on hover this one won and the group's links
 * collapsed to zero height UNDER THE CURSOR. Move away, they come back: the blink the owner saw.
 *
 * `:focus-within` was the worse half. A keyboard user tabbing into a footer link focused inside the
 * panel, which hid the panel containing the element they had just focused.
 *
 * Scoping to `.sf-nav` is one answer rather than a footer counter-rule: every `.sf-nav-item` is in
 * exactly one of the two containers and `.sf-footer-menu` is not `.sf-nav`, so the header keeps its
 * behaviour byte for byte and the footer is simply never addressed. The mobile accordion is a
 * separate `@media (max-width: 760px)` rule and is untouched. */
.sf-nav-js .sf-nav .sf-nav-item:hover > .sf-nav-panel,
.sf-nav-js .sf-nav .sf-nav-item:focus-within > .sf-nav-panel { display: none; }
/* ⚠️ BUT A COLUMN INSIDE A MEGA IS NOT A DROPDOWN, AND THE RULE ABOVE WAS CLOSING ONE.
 *
 * `.sf-nav-item` is not unique to the top level: each column of a mega panel is one too, with its
 * links in a `.sf-nav-panel` of its own. The rule above says "once the script runs, hover must not
 * open a panel" — aimed at the top level, where nav.js owns the state. Applied to a column it says
 * something else entirely: hovering a column HIDES that column's links, while its four neighbours
 * stay put. The owner sent a screenshot of exactly that, cursor on "Prefilled Vape Kits" with the
 * eight links under it gone.
 *
 * A column is permanently open by design — `.sf-mega .sf-nav-panel { display: grid }` is what draws
 * the mega's columns at all. Six parts to the suppression's five, so the columns win, and scoped
 * inside `.sf-mega` so nothing at the top level changes. */
.sf-nav-js .sf-nav .sf-mega .sf-nav-item:hover > .sf-nav-panel,
.sf-nav-js .sf-nav .sf-mega .sf-nav-item:focus-within > .sf-nav-panel { display: grid; }
/* ⚠️ HOVER-TO-OPEN IS DELIBERATELY *NOT* DONE IN CSS HERE (owner, 2026-09-10: "why this delay").
 *
 * There was no delay: click to painted panel measures 11ms. Hover simply did nothing, so a mouse
 * user hovered, waited, and eventually clicked — and read that wait as the site being slow.
 *
 * The obvious fix is a `@media (hover: hover)` rule restoring `display: grid` on `:hover`. It is
 * wrong. Once the script has run, a closed panel also carries the `hidden` ATTRIBUTE (nav.js is the
 * state), and `hidden` is what assistive technology reads. A CSS rule that outranks
 * `.sf-nav-panel[hidden]` paints a panel on screen while it still announces itself as hidden — the
 * markup and the picture disagreeing, which is the class of bug nav.js exists to end.
 *
 * So hover opens the panel in nav.js, through the same path a click takes: `hidden` comes off and
 * `aria-expanded` goes true. One state, three ways in — pointer, click, keyboard. */
/* ⚠️ `.sf-nav` IS IN THIS SELECTOR TO WIN A SPECIFICITY FIGHT, NOT FOR SCOPE — and that fight is why
 * hovering a menu did nothing for the owner through three attempted fixes.
 *
 * The suppression rule above is FIVE compound parts (`.sf-nav-js .sf-nav .sf-nav-item:hover >
 * .sf-nav-panel`). This one was four, so while the pointer was ON the item the suppression outranked
 * it and forced `display: none` — at exactly the moment the panel was meant to appear. nav.js was
 * doing its job the whole time: `hidden` came off and `aria-expanded` went true, and the stylesheet
 * quietly overruled it for as long as the mouse stayed there. Move away and `:hover` stops matching,
 * but `mouseleave` has already closed it. The panel could therefore never be seen.
 *
 * ⚠️ AND THIS IS WHY EVERY TEST PASSED. Dispatching `mouseenter` at an element does not put the
 * document into a `:hover` state, so in the harness the suppression never applied and the panel
 * always opened. The tests exercised the JavaScript and never the cascade it had to survive.
 *
 * Adding `.sf-nav` makes this five parts too, and it is later in the file, so it wins the tie. The
 * phone block's copy at ~:1977 is deliberately left alone — the drawer has no hover to fight. */
.sf-nav-js .sf-nav .sf-nav-toggle[aria-expanded="true"] + .sf-nav-panel { display: grid; }

/* ── THE MEGA PANEL ────────────────────────────────────────────────────────────────────────────
   Level 2 becomes a column heading and level 3 the entries under it — one panel laid out wide, and
   deliberately never a nested hover dropdown. The column count is a SERVER-CHOSEN CLASS from a
   closed list: under `default-src 'self'` a style attribute is dropped by the browser, so a custom
   property fed from merchant input would be a control that silently did nothing. */
.sf-mega { display: grid; gap: 10px 26px; padding: 16px 18px; min-width: 440px; right: 0; left: auto; }
.sf-mega-c2 { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
.sf-mega-c3 { grid-template-columns: repeat(3, minmax(140px, 1fr)); }
.sf-mega-c4 { grid-template-columns: repeat(4, minmax(130px, 1fr)); }
.sf-mega-c5 { grid-template-columns: repeat(5, minmax(120px, 1fr)); }
.sf-mega-full { left: 0; right: 0; width: auto; }
/* ⚠️ MUST OUTLIVE EVERY `display` RULE ABOVE. `hidden` is a UA style at the lowest specificity, so
   ANY author `display` — `.sf-mega { display: grid }` in particular — silently beats it and a closed
   panel renders wide open on page load. nav.js sets the attribute and this is what makes it mean
   something. */
.sf-nav-panel[hidden] { display: none; }
.sf-mega > .sf-nav-l2 { display: contents; }
.sf-mega .sf-nav-item { display: grid; align-content: start; }
/* ⚠️ BOLD ONLY WHERE THE ITEM IS ACTUALLY A COLUMN HEADING — `:has(> .sf-nav-panel)`.
   A mega's level-2 items are normally column titles with links beneath them, and bold is what says
   so. But a menu can have a single level: "New" holds four plain links and nothing under them, and
   this rule made all four bold, so a flat list read as four headings with no contents. Weight is
   information here — it marks "there is more below" — and applying it to a leaf states something
   untrue. Where the item has no panel it now inherits the panel's normal link weight. */
.sf-mega .sf-nav-item:has(> .sf-nav-panel) > .sf-nav-link { font-weight: 700; }
/* Inside a mega panel a level-3 list is a plain column, so the toggle beside its heading has nothing
   to disclose and the wing positioning does not apply. */
.sf-mega .sf-nav-panel { position: static; display: grid; min-width: 0; padding: 2px 0 0;
  border: 0; background: none; box-shadow: none; }
.sf-mega .sf-nav-panel .sf-nav-link { padding: 4px 0; font-size: 13.5px; color: var(--sf-muted); }
.sf-mega .sf-nav-panel .sf-nav-link:hover { background: none; color: var(--sf-ink); }
.sf-mega .sf-nav-toggle { display: none; }
.sf-mega-promo { display: grid; align-content: start; gap: 6px; padding: 12px;
  background: var(--sf-surface); border-radius: var(--sf-radius); }
.sf-mega-img { width: 100%; height: auto; border-radius: calc(var(--sf-radius) / 1.5); }
.sf-mega-h { font-size: 14px; }
.sf-mega-t { font-size: 13px; color: var(--sf-muted); }
.sf-mega-cta { font-size: 13px; font-weight: 600; color: var(--sf-brand); }

/* ── THE BURGER AND THE DRAWER ─────────────────────────────────────────────────────────────────
   The burger is the mobile rendering of the nav, NOT a header slot: a slot could be dragged into the
   "not shown" pool and strand every phone visitor. Above the breakpoint it is not rendered at all. */
.sf-nav-burger { display: none; padding: 4px; border: 0; background: none; color: inherit;
  cursor: pointer; line-height: 0; }

@media (max-width: 760px) {
  .sf-nav-burger { display: inline-flex; }
  /* The whole tree becomes a full-height accordion: same markup, same toggles, same aria-expanded,
     which is why a third level costs these few rules and not a second navigation. */
  /* ⚠️ `color` IS AS LOAD-BEARING AS `background` HERE, AND ITS ABSENCE COST THE WHOLE MENU.
     The drawer paints itself `--sf-surface` (white) but used to set no foreground, so every
     top-level label kept INHERITING the header's white — the header bar is dark green with white
     text, which is correct up there and invisible down here. Measured on a phone viewport before
     the fix: drawer background rgb(255,255,255), L1 link colour rgb(255,255,255), contrast 1.00.
     The labels were all present, laid out and clickable; they were the same colour as what was
     behind them, so a shopper saw a stack of horizontal rules.

     It hid for so long because the SUB-panels looked fine. `.sf-nav-panel` sets `--sf-ink` for its
     desktop dropdown, so an opened section rendered readable children under a row of invisible
     parents — which reads as "the menu is half broken", not "the colour is inherited".

     Anything that paints a background on a container the header's text flows into must set a
     colour too. Guarded by DrawerSetsAForegroundTest. */
  .sf-nav-tree { display: none; position: fixed; top: var(--sf-nav-top, 56px); left: 0; right: 0;
    bottom: 0; z-index: 80; overflow-y: auto; padding: 14px 18px 28px;
    background: var(--sf-surface); color: var(--sf-ink);
    border-top: 1px solid var(--sf-border); }
  .sf-nav-burger[aria-expanded="true"] + .sf-nav-tree { display: block; }
  .sf-nav-l1 { display: grid; gap: 2px; align-items: stretch; }
  /* `flex-wrap` plus a full-width panel is what makes this an ACCORDION rather than three things on
     one line. Without it the panel is simply a third flex child beside the link and the chevron, and
     a three-level menu renders as a narrow column squeezed against the label. */
  .sf-nav-l1 > .sf-nav-item { display: flex; flex-wrap: wrap; justify-content: space-between;
    align-items: center; width: 100%; padding: 4px 0; border-bottom: 1px solid var(--sf-border); }
  .sf-nav-panel, .sf-mega { position: static; display: none; width: auto; min-width: 0;
    flex-basis: 100%; padding: 2px 0 8px 14px; border: 0; background: none; box-shadow: none;
    grid-template-columns: 1fr; justify-items: start; text-align: left; }
  /* Every level below the first is a plain stacked row, so the same markup reads as one list at any
     depth instead of inheriting the desktop panel's column layout. */
  .sf-nav-panel .sf-nav-item { display: flex; flex-wrap: wrap; width: 100%; justify-content: space-between; }
  .sf-mega > .sf-nav-l2 { display: grid; gap: 2px; width: 100%; }
  .sf-nav-js .sf-nav-toggle[aria-expanded="true"] + .sf-nav-panel { display: grid; }
  .sf-mega .sf-nav-toggle { display: inline-flex; }

  /* ⚠️ THE LEVEL-3 CHEVRON INSIDE A MEGA PANEL WAS A DEAD CONTROL ON EVERY PHONE, and the cause was
     one rule outranking another from outside this block.

     `.sf-mega .sf-nav-panel { display: grid }` (:1193) sits OUTSIDE any media query at (0,2,0). It
     therefore beat `.sf-nav-panel[hidden] { display: none }` — same specificity, earlier in source —
     and it beat this block's own `.sf-nav-panel, .sf-mega { display: none }`, which is only (0,1,0).
     So a level-3 panel in a mega menu was `grid` collapsed AND `grid` expanded: the chevron rotated,
     `aria-expanded` flipped, nav.js added and removed `hidden`, and nothing on screen moved.

     (0,3,0): out-ranks :1193, and stays UNDER the expanded rule above at (0,4,0). No `!important`,
     and :1193 is untouched because it is correct at desktop widths. */
  .sf-mega .sf-nav-panel[hidden] { display: none; }

  /* ── THE FOOTER ACCORDION, WHICH HAD NEVER EXISTED AT ANY WIDTH ────────────────────────────
     `_menu_tree.html.twig` emits a disclosure button for every top-level footer column and its own
     header says the button is *"rendered always, hidden by CSS above the breakpoint"* — but
     `.sf-footer-menu .sf-nav-toggle { display: none }` (:1265) is UNCONDITIONAL, and the only footer
     media query changes `gap`. The button was hidden at every width.

     ⚠️ AND THE HALF THAT MATTERED MORE WAS AN ACCESSIBILITY REGRESSION. Below 760px nav.js sets
     `hidden` on footer panels (nav.js:166-181) — while `.sf-footer-menu .sf-nav-panel { display:
     grid }` (:1261, (0,2,0)) beat `[hidden]` and kept them on screen. `hidden` also removes an
     element from the accessibility tree, so every footer link on a phone was VISIBLE TO SIGHTED
     USERS AND ABSENT FROM ASSISTIVE TECH. The stylesheet's own note at :1257-1260 says a footer
     that is visible but unreadable is worse than either outcome; that is exactly what shipped.

     Closed by CSS rather than by waiting for `hidden`, so the two mechanisms now AGREE instead of
     contradicting, and there is no open-then-collapse flash on load. Gated on `.sf-nav-js` because
     without the script nothing can open it again: no script means the columns stay open exactly as
     they are today, and the button stays hidden rather than becoming an inert control. */
  .sf-nav-js .sf-footer-menu .sf-nav-toggle { display: inline-flex; }
  .sf-nav-js .sf-footer-menu .sf-nav-panel { display: none; }

  .sf-mega-promo { margin-top: 8px; }
  .sf-nav-panel .sf-nav-panel { left: auto; top: auto; }
}

/* ── THE DRAWER AT DESKTOP, BY CHOICE (PRO CHROME PC-6, 2026-08-13) ───────────────────────────
   The nav widget's `nav_mode: drawer` — vapeuk's "All" button. This block MIRRORS the phone block
   above rule for rule, because it is the same drawer at a different width and CSS cannot OR a
   media query with a selector; a change to the accordion up there that is not repeated here splits
   the drawer into two behaviours. Everything is keyed on `.sf-nav-js`: with scripts off the mode
   is inert and the links render across the bar exactly as an unconfigured shop's do, because a
   button that can never open again must not be the only door to the catalogue (the footer
   accordion's rule). `.sf-nav-alldrawer` reaches the page only through the template's whitelist —
   one equality, one value.

   The one rule with no phone twin: the burger's visually-hidden "Menu" label is UNCLIPPED here.
   On a phone the bars glyph is a convention; on a 1400px screen an unlabelled icon in a roomy bar
   is just mysterious, and the accessible name was already there to show. */
@media (min-width: 761px) {
  .sf-nav-js .sf-nav-alldrawer .sf-nav-burger { display: inline-flex; gap: 7px; align-items: center;
    line-height: 1; font-size: 14.5px; font-weight: 600; }
  .sf-nav-js .sf-nav-alldrawer .sf-nav-burger .sf-vh { position: static; width: auto; height: auto;
    margin: 0; padding: 0; overflow: visible; clip: auto; clip-path: none; white-space: nowrap; }
  /* Same omission as the phone drawer, same fix: this variant paints `--sf-surface` over the dark
     header too, so without `color` its top-level labels are white on white. Never reported only
     because no shop currently uses this header arrangement. */
  .sf-nav-js .sf-nav-alldrawer .sf-nav-tree { display: none; position: fixed;
    top: var(--sf-nav-top, 56px); left: 0; right: 0; bottom: 0; z-index: 80; overflow-y: auto;
    padding: 14px 18px 28px; background: var(--sf-surface); color: var(--sf-ink);
    border-top: 1px solid var(--sf-border); }
  .sf-nav-js .sf-nav-alldrawer .sf-nav-burger[aria-expanded="true"] + .sf-nav-tree { display: block; }
  .sf-nav-js .sf-nav-alldrawer .sf-nav-l1 { display: grid; gap: 2px; align-items: stretch; }
  .sf-nav-js .sf-nav-alldrawer .sf-nav-l1 > .sf-nav-item { display: flex; flex-wrap: wrap;
    justify-content: space-between; align-items: center; width: 100%; padding: 4px 0;
    border-bottom: 1px solid var(--sf-border); }
  .sf-nav-js .sf-nav-alldrawer .sf-nav-panel,
  .sf-nav-js .sf-nav-alldrawer .sf-mega { position: static; display: none; width: auto; min-width: 0;
    flex-basis: 100%; padding: 2px 0 8px 14px; border: 0; background: none; box-shadow: none;
    grid-template-columns: 1fr; justify-items: start; text-align: left; }
  .sf-nav-js .sf-nav-alldrawer .sf-nav-panel .sf-nav-item { display: flex; flex-wrap: wrap;
    width: 100%; justify-content: space-between; }
  .sf-nav-js .sf-nav-alldrawer .sf-mega > .sf-nav-l2 { display: grid; gap: 2px; width: 100%; }
  /* The chevron fix BEFORE the open rule: they tie at (0,5,0), so source order is what keeps an
     expanded panel winning over a stale [hidden] — the same ordering the phone block gets from
     specificity alone, pinned the same way by MobileNavigationCascadeTest. */
  .sf-nav-js .sf-nav-alldrawer .sf-mega .sf-nav-panel[hidden] { display: none; }
  .sf-nav-js .sf-nav-alldrawer .sf-nav-toggle[aria-expanded="true"] + .sf-nav-panel { display: grid; }
  .sf-nav-js .sf-nav-alldrawer .sf-mega .sf-nav-toggle { display: inline-flex; }
  .sf-nav-js .sf-nav-alldrawer .sf-mega-promo { margin-top: 8px; }
  .sf-nav-js .sf-nav-alldrawer .sf-nav-panel .sf-nav-panel { left: auto; top: auto; }
}
/* Scroll lock while the drawer is open — a CLASS, because an inline style is dropped by the CSP. */
.sf-nav-locked { overflow: hidden; }

/* Visually hidden but still read out: the toggle needs an accessible name, and the chevron cannot
   be one. Clip rather than `display:none`, which would remove it from the accessibility tree too. */
.sf-vh { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }

/* ── THE FOOTER, drawing the SAME tree ─────────────────────────────────────────────────────────
   A footer column's links are always visible, so no toggle is emitted above the phone breakpoint
   and level 3 is simply a list inside a list — which costs nothing and is an independent argument
   for the depth-3 data cap. */
.sf-footer-menu > .sf-nav-l1 { display: flex; flex-wrap: wrap; gap: 14px 28px; margin-bottom: 14px; }
.sf-footer-menu.sf-footer-columns > .sf-nav-l1 { display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 20px; }
.sf-footer-menu > .sf-nav-l1 > .sf-nav-item { display: grid; align-content: start; position: static; }
.sf-footer-menu.sf-footer-columns .sf-nav-l1 > .sf-nav-item > .sf-nav-link { font-weight: 700; margin-bottom: 6px; }
.sf-footer-menu.is-caps .sf-nav-l1 > .sf-nav-item > .sf-nav-link { text-transform: uppercase;
  letter-spacing: .04em; font-size: 12.5px; }
.sf-footer-menu .sf-nav-l1 > .sf-nav-item > .sf-nav-link { font-size: 14px; }
/* The panel is a plain open list here — there is nothing to disclose above the breakpoint. No
   `!important`: nav.js REMOVES the `hidden` attribute from footer panels on a wide viewport rather
   than leaving it on and letting CSS paint over it, because `hidden` also takes an element out of
   the accessibility tree and a footer that is visible but unreadable is worse than either. */
/* Giving up the ground gives up the ink with it — see the `.sf-nav-panel` note. Without the
   `color: inherit` the footer panel would carry the dropdown's `--sf-ink` onto the footer BAND,
   which is the same dark-on-dark failure in the other direction. */
.sf-footer-menu .sf-nav-panel { position: static; display: grid; gap: 4px; margin-top: 4px;
  min-width: 0; max-width: none; padding: 0; border: 0; background: none; color: inherit;
  box-shadow: none; }
.sf-footer-menu .sf-nav-panel .sf-nav-link { padding: 0; font-size: 13.5px; color: var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-muted))); }
.sf-footer-menu .sf-nav-panel .sf-nav-link:hover { background: none; color: var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-ink))); }
.sf-footer-menu .sf-nav-toggle { display: none; }
@media (max-width: 700px) { .sf-footer-menu > .sf-nav-l1 { gap: 10px 18px; } }

/* ── A BLOCK ON A PAGE, and its Design tab ─────────────────────────────────────────────────────
   The wrapper (0076) carries the block's own class, its anchor id and its scoped CSS. The scroll
   margin is for the anchor: with a sticky header, jumping to #some-block otherwise lands the block
   underneath it.

   EVERYTHING ELSE HERE IS THE DESIGN GROUP, and every declaration below is written the same way for
   the same reason: `var(--sf-sec-…, <the value this rule had before>)`. The storefront CSP has no
   `style-src`, so a merchant's choice reaches the page as either a class we chose on the server or
   a custom property in the store's generated stylesheet — and every design default in the schema is
   BLANK. Read bare, `padding-block: var(--sf-sec-pad)` would collapse the padding of every block on
   three trading shops that nobody has touched. Read with a fallback, an unset property means the
   rule keeps the value it always had. The fallback IS the contract; do not remove one.

   The per-side pair is two levels deep — `var(--sf-sec-pad-top, var(--sf-sec-pad, 0px))` — which is
   what makes "vertical padding 40, then top 90" mean the plain English thing without the schema
   knowing any CSS.

   ONE property is deliberately read with NO fallback. `translate: 0 0px` still creates a stacking
   context and a containing block, so declaring it on every block would quietly change how anything
   positioned inside them resolves. With `--sf-sec-offset-y` unset the declaration is invalid at
   computed-value time and `translate` falls back to its initial `none` — exactly as if it were
   never written. That is the trick, and it is the reason this one line looks wrong.

   `opacity` (0098) is the near-miss that proves the rule is about the PROPERTY and not about the
   style: it is read WITH a fallback of 1, because `opacity: 1` creates no stacking context at all,
   where `translate: 0 0px` does. Same shape of question, opposite answer, and getting it wrong in
   either direction is invisible until something inside a block positions itself against the wrong
   ancestor. */
.sf-block {
  scroll-margin-top: 5rem;
  min-height: var(--sf-sec-min-h, auto);
  opacity: var(--sf-sec-opacity, 1);
  padding-block: var(--sf-sec-pad-top, var(--sf-sec-pad, 0px)) var(--sf-sec-pad-bottom, var(--sf-sec-pad, 0px));
  padding-inline: var(--sf-sec-pad-left, var(--sf-sec-pad-x, 0px)) var(--sf-sec-pad-right, var(--sf-sec-pad-x, 0px));
  /* `css_margin` on the Dev tab is a whole shorthand and WINS over the four sides, the same way a
     block's own custom_css wins over its controls — the escape hatch has to be able to escape. */
  margin: var(--sf-sec-margin,
    var(--sf-sec-mar-top, 0px) var(--sf-sec-mar-right, 0px)
    var(--sf-sec-mar-bottom, 0px) var(--sf-sec-mar-left, 0px));
  translate: 0 var(--sf-sec-offset-y);
  border: var(--sf-sec-bd-w, 0px) var(--sf-sec-bd-style, solid) var(--sf-sec-bd-colour, transparent);
  /* FOUR CORNERS (0100), each deferring to the one radius control — the same two-level `var()` the
     per-side padding uses, and it means the same thing: an unset corner is not "zero", it is
     "whatever `radius` said", and an unset `radius` is the 0px this rule has always resolved to.
     Written as the four longhands rather than the shorthand's four slots because a shorthand with
     four `var()`s in it is invalid as a whole the moment ANY one of them is unset, which would
     throw away the corner the merchant did set. */
  border-start-start-radius: var(--sf-sec-radius-tl, var(--sf-sec-radius, 0px));
  border-start-end-radius: var(--sf-sec-radius-tr, var(--sf-sec-radius, 0px));
  border-end-end-radius: var(--sf-sec-radius-br, var(--sf-sec-radius, 0px));
  border-end-start-radius: var(--sf-sec-radius-bl, var(--sf-sec-radius, 0px));
  /* INNER SHADOW (0101), and this is why it is ORDERED rather than composed. `box-shadow` is one
     property; `box-shadow: none, inset 0 …` is invalid as a whole, so a list built from two custom
     properties would be thrown away entirely the moment a merchant had set only one of the pair.
     Nested instead, the inner shadow WINS where it is set, the outer one is what is left, and an
     untouched block resolves to the `none` this rule has always resolved to. */
  box-shadow: var(--sf-sec-shadow-inset, var(--sf-sec-shadow, none));
  /* FOUR background layers now (0098), in paint order: the shade over the picture, the picture over
     the pattern, the pattern over the gradient, all of them over the colour. A background-* list
     shorter than the layer count is cycled, so ONE size / position / repeat value still covers every
     layer — which is why there is no repetition here.

     Two layers became four and nothing moved on any shop, because every added layer resolves to
     `none` until a merchant picks one, and four transparent layers paint exactly what one paints.
     The gradient and the pattern are drawn by the browser from a closed list, so neither costs a
     download and neither can carry a URL into the stylesheet. */
  background-color: var(--sf-sec-bg, transparent);
  background-image: var(--sf-sec-bg-overlay, none), var(--sf-sec-bg-img, none),
    var(--sf-sec-bg-pattern, none), var(--sf-sec-bg-grad, none);
  background-size: var(--sf-sec-bg-size, auto);
  background-position: var(--sf-sec-bg-pos, 50% 50%);
  background-repeat: var(--sf-sec-bg-repeat, repeat);
  color: var(--sf-sec-text-colour, inherit);
  font-family: var(--sf-sec-text-font, inherit);
  font-size: var(--sf-sec-text-size, inherit);
  /* Body text weight (0100). `inherit` is the fallback and it is exactly a no-op: `font-weight`
     inherits, so declaring the inherited value changes nothing on any block that has not asked.
     Headings inside the block are unaffected either way — each has a `font-weight` declaration of
     its own, at a higher specificity than this one. */
  font-weight: var(--sf-sec-text-weight, inherit);
  /* TEXT SHADOW (0099) is read BARE, and it is the third answer to the question `translate` and
     `opacity` above ask. `text-shadow` INHERITS, so an unset property makes this declaration invalid
     at computed-value time and an inherited property that is invalid computes to `inherit` — the
     value it already had, from wherever it already had it. Written with a `none` fallback instead,
     every block on three trading shops would start asserting "no shadow" over whatever the page
     around it had decided, which is a change even though `none` is the initial value. */
  text-shadow: var(--sf-sec-text-shadow);
}

/* The closed lists, as the classes the server chose. A class costs the generated stylesheet nothing
   — it is in this file once for every store — which is why anything with a countable set of answers
   is one, and only the continuous values become properties. */
.sf-align-left { text-align: left; }
.sf-align-center { text-align: center; }
.sf-align-right { text-align: right; }

/* Content width caps the block, not the page: `margin-inline: auto` is what centres what is left. */
.sf-w-full { max-width: none; }
.sf-w-1280 { max-width: 1280px; margin-inline: auto; }
.sf-w-1120 { max-width: 1120px; margin-inline: auto; }
.sf-w-960 { max-width: 960px; margin-inline: auto; }
.sf-w-760 { max-width: 760px; margin-inline: auto; }

.sf-bds-solid { --sf-sec-bd-style: solid; }
.sf-bds-dashed { --sf-sec-bd-style: dashed; }
.sf-bds-dotted { --sf-sec-bd-style: dotted; }
.sf-bds-double { --sf-sec-bd-style: double; }

/* THE THEME'S OWN FOUR ELEVATIONS, not a second vocabulary of shadows. These literals are
   ThemeTokens::SHADOWS, and `testTheBlockShadowsAreTheThemesOwnFour` reads this file to prove they
   have not drifted — a shop should never have two ideas of what "Soft" means. `none` has no rule
   because the emitter skips that value and the fallback above is already `none`. */
.sf-shadow-subtle { --sf-sec-shadow: 0 1px 3px rgba(20,24,40,.08); }
.sf-shadow-soft { --sf-sec-shadow: 0 2px 8px rgba(20,24,40,.10); }
.sf-shadow-lifted { --sf-sec-shadow: 0 6px 18px rgba(20,24,40,.14); }

.sf-bgsz-cover { --sf-sec-bg-size: cover; }
.sf-bgsz-contain { --sf-sec-bg-size: contain; }
.sf-bgp-center { --sf-sec-bg-pos: 50% 50%; }
.sf-bgp-top { --sf-sec-bg-pos: 50% 0%; }
.sf-bgp-bottom { --sf-sec-bg-pos: 50% 100%; }
.sf-bgp-left { --sf-sec-bg-pos: 0% 50%; }
.sf-bgp-right { --sf-sec-bg-pos: 100% 50%; }
.sf-bgp-top-left { --sf-sec-bg-pos: 0% 0%; }
.sf-bgp-top-right { --sf-sec-bg-pos: 100% 0%; }
.sf-bgp-bottom-left { --sf-sec-bg-pos: 0% 100%; }
.sf-bgp-bottom-right { --sf-sec-bg-pos: 100% 100%; }
.sf-bgrp-no-repeat { --sf-sec-bg-repeat: no-repeat; }
.sf-bgrp-repeat { --sf-sec-bg-repeat: repeat; }
.sf-bgrp-repeat-x { --sf-sec-bg-repeat: repeat-x; }
.sf-bgrp-repeat-y { --sf-sec-bg-repeat: repeat-y; }

/* THE SCRIM. A flat gradient rather than a `::before` layer, so it cannot swallow a click and no
   block needs `position: relative` to hold it. The vocabulary is the banner's and the slideshow's
   own `overlay` — none / light / dark / a fade from the bottom — because a merchant who shaded a
   slide yesterday should not have to learn a second word for it today. */
.sf-bgov-light { --sf-sec-bg-overlay: linear-gradient(rgba(255,255,255,.58), rgba(255,255,255,.58)); }
.sf-bgov-dark { --sf-sec-bg-overlay: linear-gradient(rgba(12,15,22,.52), rgba(12,15,22,.52)); }
.sf-bgov-gradient { --sf-sec-bg-overlay: linear-gradient(rgba(12,15,22,0) 32%, rgba(12,15,22,.74)); }

/* ══ THE DEEPENING (0098) — everything below is a class, and a class is free ═══════════════════
   Sixteen shared controls were added in this slice and eleven of them are here, because eleven had
   a countable set of answers. A class lives in THIS file once for every store on the platform; a
   custom property lives in every store's generated stylesheet, once per block that set it. That is
   the whole reason the schema asks "is the value list closed?" before it asks anything else. */

/* Content that sticks out. Trimming is what makes `radius` actually round off a picture inside a
   block — the commonest "why is my corner still square" in any builder. */
.sf-ovf-hidden { overflow: hidden; }
.sf-ovf-scroll-x { overflow-x: auto; overflow-y: hidden; }

/* VERTICAL ALIGNMENT (0100), as one custom property the row-shaped rules already read — the banner,
   the card grids, the promises row, the quotes and the brand strip. One class, five places, and
   each of those rules keeps its own former value as the fallback, so setting nothing changes
   nothing anywhere.
   The merchant's words become CSS's: "Top" is `start`, not `flex-start`, because these rules are a
   mix of grid and flex and the logical keywords are the pair that means the same thing in both. */
.sf-aligny-top { --sf-sec-align-y: start; }
.sf-aligny-center { --sf-sec-align-y: center; }
.sf-aligny-bottom { --sf-sec-align-y: end; }
.sf-aligny-stretch { --sf-sec-align-y: stretch; }
/* BASELINE (0101) — the fifth answer, and the only one about the words rather than the boxes. It
   lines up each item's FIRST LINE OF TEXT, which is what a row of cards with unequal headings
   actually wants. Legal in both grid and flex, like the four above it. */
.sf-aligny-baseline { --sf-sec-align-y: baseline; }

/* WHICH SIDES the border is drawn on. Each of these zeroes the sides it is not, so `bd_width` and
   `bd_colour` keep meaning exactly what they meant — one control decides thickness and colour, this
   one decides where. Logical properties, so a right-to-left shop is right without a second list. */
.sf-bdside-top { border-inline-width: 0; border-block-end-width: 0; }
.sf-bdside-bottom { border-inline-width: 0; border-block-start-width: 0; }
.sf-bdside-y { border-inline-width: 0; }
.sf-bdside-x { border-block-width: 0; }
.sf-bdside-left { border-block-width: 0; border-inline-end-width: 0; }
.sf-bdside-right { border-block-width: 0; border-inline-start-width: 0; }

/* GRADIENTS. The first two are built from the store's OWN brand token, so they follow the theme
   rather than freezing a colour into a block — change the brand colour and these change with it.
   `color-mix` rather than a hard-coded rgba, for exactly that reason. */
.sf-bggr-brand-soft { --sf-sec-bg-grad: linear-gradient(180deg, color-mix(in srgb, var(--sf-brand) 16%, transparent), transparent); }
.sf-bggr-brand-sweep { --sf-sec-bg-grad: linear-gradient(135deg, color-mix(in srgb, var(--sf-brand) 22%, transparent), transparent 62%); }
.sf-bggr-ink-fade { --sf-sec-bg-grad: linear-gradient(0deg, rgba(12,15,22,.30), transparent 58%); }
.sf-bggr-warm { --sf-sec-bg-grad: linear-gradient(160deg, rgba(255,203,138,.34), rgba(255,241,224,.10) 70%); }
.sf-bggr-cool { --sf-sec-bg-grad: linear-gradient(160deg, rgba(150,196,232,.30), rgba(232,241,249,.08) 70%); }
/* 0101. A glow from the middle and a fade DOWN from the top — two shapes the five above cannot
   make. The radial follows the brand token for the same reason the first two do: change the brand
   colour and it changes with it, rather than freezing yesterday's colour into a block. */
.sf-bggr-brand-radial { --sf-sec-bg-grad: radial-gradient(circle at 50% 45%, color-mix(in srgb, var(--sf-brand) 20%, transparent), transparent 68%); }
.sf-bggr-ink-top { --sf-sec-bg-grad: linear-gradient(180deg, rgba(12,15,22,.28), transparent 58%); }

/* PATTERNS, drawn by the browser rather than downloaded. Every one is a repeating gradient with
   pixel stops, which tiles on its own — so none of them needs a `background-size` of its own, and
   none of them fights the picture's size, position or repeat if a block has both. */
.sf-bgpat-stripes { --sf-sec-bg-pattern: repeating-linear-gradient(90deg, rgba(12,15,22,.045) 0 6px, transparent 6px 16px); }
.sf-bgpat-diagonal { --sf-sec-bg-pattern: repeating-linear-gradient(45deg, rgba(12,15,22,.045) 0 6px, transparent 6px 16px); }
.sf-bgpat-grid { --sf-sec-bg-pattern: repeating-linear-gradient(0deg, rgba(12,15,22,.05) 0 1px, transparent 1px 24px), repeating-linear-gradient(90deg, rgba(12,15,22,.05) 0 1px, transparent 1px 24px); }
.sf-bgpat-hairline { --sf-sec-bg-pattern: repeating-linear-gradient(0deg, rgba(12,15,22,.06) 0 1px, transparent 1px 8px); }
/* DOTS AND CHECKS WERE TRIED HERE (0101) AND DROPPED, which is worth a line so nobody adds them
   back. Every pattern above is a repeating gradient with pixel stops, so each tiles itself and
   needs no `background-size`. A dot needs a radial gradient and a check needs a conic one, and
   neither tiles without one — so both would have had to write `--sf-sec-bg-size`, which is the
   property "Picture size" already owns. Two controls writing one property, with source order
   deciding which wins, is exactly the divergence this file refuses everywhere else. */

/* HOW THE LAYERS MIX. One value covers every layer, which is the honest behaviour: "multiply" means
   multiply the whole stack against the colour underneath, not one chosen pair. */
.sf-bgbl-multiply { background-blend-mode: multiply; }
.sf-bgbl-screen { background-blend-mode: screen; }
.sf-bgbl-overlay { background-blend-mode: overlay; }
.sf-bgbl-soft-light { background-blend-mode: soft-light; }
.sf-bgbl-luminosity { background-blend-mode: luminosity; }

/* PARALLAX, and the reason it is behind a query rather than offered flat. `background-attachment:
   fixed` is ignored by iOS Safari and janks badly on Android, and phones are most of a UK vape
   shop's traffic. Scoped to a pointer device on a wide screen, it does what it promises where it
   can and nothing where it cannot — and the control's own label says "on computers" out loud, so a
   merchant is never left wondering why their phone looks different. */
@media (hover: hover) and (min-width: 1024px) {
  .sf-bgat-fixed { background-attachment: fixed; }
}

/* FONT FAMILY, per block. These seven stacks ARE ThemeTokens::FONTS, character for character, and
   `testTheBlockFontsAreTheThemesOwnSeven` reads this file to prove they have not drifted — a shop
   must not have two ideas of what "Classic serif" means. Setting `--sf-font-heading` is all the
   heading rules need, because they already read it and custom properties inherit. */
.sf-hfont-system { --sf-font-heading: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.sf-hfont-inclusive { --sf-font-heading: "Inclusive Sans", system-ui, -apple-system, sans-serif; }
.sf-hfont-humanist { --sf-font-heading: Optima, Candara, "Noto Sans", source-sans-pro, sans-serif; }
.sf-hfont-geometric { --sf-font-heading: Futura, "Trebuchet MS", Arial, sans-serif; }
.sf-hfont-neogrotesque { --sf-font-heading: Inter, Roboto, "Helvetica Neue", Arial, sans-serif; }
.sf-hfont-serif { --sf-font-heading: Charter, Georgia, Cambria, "Times New Roman", serif; }
.sf-hfont-slab { --sf-font-heading: Rockwell, "Courier Bold", Georgia, serif; }
.sf-hfont-rounded { --sf-font-heading: ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, system-ui, sans-serif; }

.sf-tfont-system { --sf-sec-text-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.sf-tfont-inclusive { --sf-sec-text-font: "Inclusive Sans", system-ui, -apple-system, sans-serif; }
.sf-tfont-humanist { --sf-sec-text-font: Optima, Candara, "Noto Sans", source-sans-pro, sans-serif; }
.sf-tfont-geometric { --sf-sec-text-font: Futura, "Trebuchet MS", Arial, sans-serif; }
.sf-tfont-neogrotesque { --sf-sec-text-font: Inter, Roboto, "Helvetica Neue", Arial, sans-serif; }
.sf-tfont-serif { --sf-sec-text-font: Charter, Georgia, Cambria, "Times New Roman", serif; }
.sf-tfont-slab { --sf-sec-text-font: Rockwell, "Courier Bold", Georgia, serif; }
.sf-tfont-rounded { --sf-sec-text-font: ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, system-ui, sans-serif; }

/* HEADING CAPITALS. `text-transform` changes how a word is DRAWN and not what the document says, so
   a screen reader still reads the heading the merchant typed — which is why this is a style control
   and not something that rewrites the stored text. */
.sf-hcase-uppercase { --sf-sec-head-case: uppercase; }
.sf-hcase-lowercase { --sf-sec-head-case: lowercase; }
.sf-hcase-capitalize { --sf-sec-head-case: capitalize; }

/* ══ 0101 — the second deepening, and every rule below it is a class ══════════════════════════ */

/* TEXT CAPITALS, the body half of the pair above. Written as the PROPERTY rather than as a custom
   property, and that is the whole difference between the two: the heading rules already declare
   `text-transform: var(--sf-sec-head-case, inherit)`, so with no heading answer given they inherit
   whatever this sets, and with one given theirs wins at the point of declaration. One inherited
   property, two controls, and the more specific one is the one that is declared closer to the text.
   An untouched block carries neither class and therefore no declaration at all. */
.sf-tcase-uppercase { text-transform: uppercase; }
.sf-tcase-lowercase { text-transform: lowercase; }
.sf-tcase-capitalize { text-transform: capitalize; }

/* SLANT. Exactly the same arrangement as capitals, one property along: the block sets `font-style`
   directly and the heading rules read `--sf-sec-head-style` with `inherit` as their fallback — so
   an italic block italicises its heading too unless the heading is told to stay upright, and a
   block with neither set declares nothing. `normal` on the heading is the only reason that control
   needs three options rather than two. */
.sf-tstyle-italic { font-style: italic; }
.sf-tstyle-normal { font-style: normal; }
.sf-hstyle-italic { --sf-sec-head-style: italic; }
.sf-hstyle-normal { --sf-sec-head-style: normal; }

/* LINK UNDERLINE. The colour is continuous and travels as a property; this is the closed half.
   Both values are already the fallbacks written into `a`, `.sf-page-body a` and `.sf-more a` at the
   top of this file, so each of those rules keeps its own default and only a block that has asked
   changes anything. `plain` is spelled out rather than left to `none`, because `none` is the value
   the emitter uses to mean "no class at all" — a merchant who wants an underline REMOVED is making
   a choice, not declining to make one. */
.sf-link-underline { --sf-sec-link-deco: underline; }
.sf-link-plain { --sf-sec-link-deco: none; }

/* INNER SHADOW. Read by `.sf-block`'s one `box-shadow`, ahead of the outer shadow — see the note
   there for why they are nested rather than composed. The three depths are deliberately shallower
   than the outer four: an inset shadow is read against the block's own background rather than
   against the page, so the same numbers would look twice as heavy. */
.sf-shadowin-subtle { --sf-sec-shadow-inset: inset 0 1px 3px rgba(20, 24, 40, .10); }
.sf-shadowin-soft { --sf-sec-shadow-inset: inset 0 2px 8px rgba(20, 24, 40, .14); }
.sf-shadowin-deep { --sf-sec-shadow-inset: inset 0 6px 18px rgba(20, 24, 40, .20); }

/* FILL THE SCREEN. A real `min-height` declaration rather than a value for `--sf-sec-min-h`, and
   that is what makes the precedence knowable: `.sf-block` reads the property, this rule sets the
   property's own longhand, and this file is loaded before the store's generated stylesheet — but
   the generated sheet only ever sets the CUSTOM PROPERTY, never `min-height` itself, so a real
   declaration here always wins. The control's help text says so out loud.

   `svh` and not `vh`: on a phone `100vh` is the height with the browser chrome HIDDEN, so a hero
   sized to it hangs under the address bar on arrival — which is the one moment this control exists
   for. `svh` is the viewport that is actually visible then. */
.sf-minh-screen { min-height: 100svh; }

/* TILT. Four angles, a class each, because the page has to be able to see one: a rotated
   full-width band overhangs the viewport at both corners, and `:has()` can match a class where it
   could never match a custom property. The clip below is the same one the slide-in entrances
   already use, for the same reason, and `clip` rather than `hidden` so the page does not gain a
   scroll container (which would break `position: sticky` on the header). */
.sf-rot-left-3 { rotate: -3deg; }
.sf-rot-left-6 { rotate: -6deg; }
.sf-rot-right-3 { rotate: 3deg; }
.sf-rot-right-6 { rotate: 6deg; }
body:has(.sf-rot-left-3, .sf-rot-left-6, .sf-rot-right-3, .sf-rot-right-6) { overflow-x: clip; }

/* A NAME FOR A BACKGROUND PICTURE, on any block. Zero-sized rather than positioned over the block:
   `role="img"` needs to be in the accessibility tree and nowhere near the layout, and an absolutely
   positioned overlay would need `.sf-block` to be `position: relative` — which it deliberately is
   not, because making it so would re-anchor anything a block type positions inside its own
   template. Not `display: none` and not `visibility: hidden`: both would remove it from the tree,
   which is the one thing it is there for. */
.sf-bg-alt { display: block; width: 0; height: 0; overflow: hidden; }

/* TEXT SHADOW (0099). Four presets rather than a free string, for the reason the box shadows give:
   a shadow a merchant types is arbitrary CSS in a file three shops are served from. `soft` and
   `glow` are the two that do the real job — white words over a photograph — and they are tuned to
   survive a light photo as well as a dark one. `crisp` is the 1px offset that reads as print rather
   than as glow. Set on the block, they inherit down to every word inside it, which is what a
   merchant means by "the text in this block". */
.sf-tsh-soft { --sf-sec-text-shadow: 0 1px 3px rgba(12, 15, 22, .45); }
.sf-tsh-strong { --sf-sec-text-shadow: 0 2px 6px rgba(12, 15, 22, .70); }
.sf-tsh-crisp { --sf-sec-text-shadow: 0 1px 0 rgba(12, 15, 22, .55); }
.sf-tsh-glow { --sf-sec-text-shadow: 0 0 10px rgba(255, 255, 255, .75); }

/* PICTURE TREATMENT — the pictures inside a block, never the block itself. A filter on the wrapper
   would fade the price and the buy button along with the photograph, and a customer cannot read a
   60%-brightness price. `img` rather than a list of the shop's own image classes, so a block type
   added tomorrow is covered tomorrow.

   The brand strip's own `is-greyscale` is deliberately more specific than these and still wins:
   it is a per-block switch on a block whose whole job is a row of logos, and it also has a hover
   behaviour these presets do not. */
.sf-imgfx-grayscale img { filter: grayscale(1); }
.sf-imgfx-sepia img { filter: sepia(.65); }
.sf-imgfx-soft img { filter: saturate(.75) contrast(.92); }
.sf-imgfx-bright img { filter: brightness(1.12) saturate(1.08); }
.sf-imgfx-dim img { filter: brightness(.82); }
.sf-imgfx-blur img { filter: blur(3px); }
/* 0101 — saturation, contrast and hue, the three of the request's group 12 that were left. Same
   preset-not-slider reasoning as the six above: `filter` is one property, and five sliders would
   have to compose into one declaration that is either declared on every image in three shops or
   dies the moment only one of the five is set. `cool` is a hue rotation kept small on purpose: a
   large one recolours a product, and a recoloured product on a regulated SKU is a claim about
   goods the shop does not stock. */
.sf-imgfx-vivid img { filter: saturate(1.35); }
.sf-imgfx-contrast img { filter: contrast(1.18); }
.sf-imgfx-cool img { filter: hue-rotate(-12deg) saturate(1.05); }

/* ── New blocks (0079): questions, quotes, brands, space, countdown ──────────────────────────── */

/* Everything below builds on the design tokens rather than fixed values, so a store that changes
   its brand colour or corner radius changes these too — the point of having tokens at all. */

.sf-faq { margin: 3rem 0; }
.sf-faq-title, .sf-quotes-title, .sf-logos-title, .sf-countdown-title {
  font-family: var(--sf-font-heading, inherit);
  font-size: var(--sf-sec-head-size, 1.5rem);
  font-weight: var(--sf-sec-head-weight, inherit);
  line-height: var(--sf-sec-head-lh, inherit);
  text-transform: var(--sf-sec-head-case, inherit);
  font-style: var(--sf-sec-head-style, inherit);
  margin: 0 0 1.25rem;
  color: var(--sf-sec-head-colour, var(--sf-ink));
}
.sf-faq-list { border-top: 1px solid var(--sf-border, #e5e7eb); }
.sf-faq-item { border-bottom: 1px solid var(--sf-border, #e5e7eb); }
.sf-faq-q {
  cursor: pointer;
  padding: 1rem 2rem 1rem 0;
  font-weight: 600;
  color: var(--sf-ink);
  position: relative;
  list-style: none;
}
.sf-faq-q::-webkit-details-marker { display: none; }
/* A rotating caret drawn in CSS — no icon font, no extra request. */
.sf-faq-q::after {
  content: "";
  position: absolute;
  right: .35rem;
  top: 1.4rem;
  width: .5rem;
  height: .5rem;
  border-right: 2px solid var(--sf-muted);
  border-bottom: 2px solid var(--sf-muted);
  transform: rotate(45deg);
  transition: transform .15s ease;
}
.sf-faq-item[open] .sf-faq-q::after { transform: rotate(-135deg); }
.sf-faq-a { padding: 0 2rem 1.1rem 0; font-size: var(--sf-sec-text-size, inherit); color: var(--sf-sec-text-colour, var(--sf-muted)); line-height: var(--sf-sec-text-lh, 1.7); }
.sf-faq-a p { margin: 0 0 .75rem; }

.sf-quotes { margin: 3rem 0; }
.sf-quotes-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--sf-sec-gap-row, 1rem) var(--sf-sec-gap-col, 1rem);
  align-items: var(--sf-sec-align-y, normal);
}
.sf-quote {
  margin: 0;
  padding: 1.25rem;
  border: 1px solid var(--sf-border, #e5e7eb);
  border-radius: var(--sf-radius, 12px);
  background: var(--sf-surface);
  box-shadow: var(--sf-shadow, none);
}
.sf-quote-text { margin: 0 0 .75rem; font-size: var(--sf-sec-text-size, inherit); color: var(--sf-sec-text-colour, var(--sf-ink)); line-height: var(--sf-sec-text-lh, 1.65); }
.sf-quote-text::before { content: "\201C"; }
.sf-quote-text::after { content: "\201D"; }
.sf-quote-by { font-size: .875rem; color: var(--sf-muted); }
.sf-quote-by cite { font-style: normal; font-weight: 600; color: var(--sf-ink); }
.sf-quote-detail::before { content: " · "; }

.sf-logos { margin: 2.5rem 0; }
.sf-logos-row {
  display: flex;
  flex-wrap: wrap;
  align-items: var(--sf-sec-align-y, center);
  justify-content: center;
  gap: var(--sf-sec-gap-row, 1.5rem) var(--sf-sec-gap-col, 2.5rem);
  margin: 0;
  padding: 0;
  list-style: none;
}
.sf-logo-img { display: block; max-height: 2.5rem; width: auto; }
.sf-logo-name { font-weight: 700; color: var(--sf-muted); }
/* Greyscale until hovered keeps a row of clashing brand colours calm. Opt-in, per block. */
.sf-logos.is-greyscale .sf-logo-img { filter: grayscale(1); opacity: .65; transition: filter .15s ease, opacity .15s ease; }
.sf-logos.is-greyscale .sf-logo-img:hover { filter: none; opacity: 1; }

.sf-divider-small { height: 1.5rem; }
.sf-divider-medium { height: 3rem; }
.sf-divider-large { height: 5rem; }
.sf-divider.has-line { background: linear-gradient(var(--sf-border, #e5e7eb), var(--sf-border, #e5e7eb)) center/100% 1px no-repeat; }

.sf-countdown {
  margin: 2.5rem 0;
  padding: 1.75rem;
  border-radius: var(--sf-radius, 12px);
  background: var(--sf-surface);
  border: 1px solid var(--sf-border, #e5e7eb);
  text-align: center;
}
.sf-countdown-clock {
  margin: 0 0 1rem;
  font-size: 1.35rem;
  font-weight: 700;
  color: var(--sf-brand);
  font-variant-numeric: tabular-nums;
}
.sf-countdown-done { margin: 0 0 1rem; color: var(--sf-muted); }
.sf-countdown-clock time { text-decoration: none; }

/* The decline button (0085). Deliberately the same size and weight as Accept: a "no" that is
   visually quieter than the "yes" is a dark pattern, and under UK guidance it is not valid consent
   either. It is ghosted rather than filled only so the two are distinguishable at a glance. */
.sf-cookie-no { padding: 8px 18px; border: 1px solid currentColor; border-radius: 999px;
  background: transparent; color: inherit; font: inherit; font-weight: 700; cursor: pointer; }

/* ── Slideshow controls (0087) ──────────────────────────────────────────────────────────────── */
/* Arrow SHAPE is the button, arrow MARK is the chevron inside it, so "plain chevron" is the same
   button with its background taken away rather than a second set of markup. */
.sf-arrow-mark { font-size: 26px; line-height: 1; }

.sf-arrows-square .sf-slider-arrow { border-radius: 8px; }
.sf-arrows-large .sf-slider-arrow { width: 54px; height: 54px; }
.sf-arrows-large .sf-arrow-mark { font-size: 34px; }
.sf-arrows-plain .sf-slider-arrow {
  width: auto; height: auto; padding: 6px; background: transparent; color: #fff;
  /* A bare chevron on a photograph can land on anything, so it carries its own shadow rather than
     relying on the picture behind it being kind. */
  text-shadow: 0 1px 6px rgba(0, 0, 0, .75);
}
.sf-arrows-plain .sf-slider-arrow:hover { background: transparent; opacity: .8; }
.sf-arrows-plain .sf-arrow-mark { font-size: 42px; }
/* A light shade means dark text, so the plain chevron has to flip with it or it disappears. */
.sf-shade-light.sf-arrows-plain .sf-slider-arrow { color: #16181d; text-shadow: 0 1px 6px rgba(255, 255, 255, .8); }

.sf-arrowpos-edge .sf-slider-prev { left: 0; }
.sf-arrowpos-edge .sf-slider-next { right: 0; }
/* Both together in one corner: the pattern for a slideshow sitting next to other content, where a
   left-hand arrow would float over the neighbour. */
.sf-arrowpos-bottom .sf-slider-arrow { top: auto; bottom: 14px; transform: none; }
.sf-arrowpos-bottom .sf-slider-prev { left: auto; right: 62px; }
.sf-arrowpos-bottom .sf-slider-next { right: 14px; }
.sf-arrowpos-bottom.sf-dots-numbers .sf-slider-dots,
.sf-arrowpos-bottom .sf-slider-dots { justify-content: flex-start; padding-left: 16px; }

/* Disabled at the ends when "start again after the last slide" is off. Faded rather than hidden, so
   the control does not move about as you reach an end. */
.sf-slider-arrow[disabled] { opacity: .35; cursor: default; }
/* Cancels the base hover, and then the plain-chevron variant cancels it again: an attribute selector
   weighs the same as a class, so `[disabled]:hover` and `.sf-arrows-plain …:hover` tie on
   specificity and source order decides. Without the second rule a disabled "no background" arrow
   grew a white pill on hover — the one state where it is meant to be doing nothing. */
.sf-slider-arrow[disabled]:hover { background: rgba(255, 255, 255, .86); }
.sf-arrows-plain .sf-slider-arrow[disabled],
.sf-arrows-plain .sf-slider-arrow[disabled]:hover { background: transparent; }

/* Bottom-right pair, sized for the LARGE arrow. The 62px offset below assumes the 40px default;
   a 54px arrow overlaps its neighbour by 6px and the two circles collide. */
.sf-arrowpos-bottom.sf-arrows-large .sf-slider-prev { right: 76px; }

/* Dot styles. The number is always in the markup and hidden here, so switching style is a class
   change rather than different HTML — and a screen reader gets the slide number in every style. */
.sf-dot-n, .sf-dot-total { display: none; }
.sf-dots-bars .sf-slider-dot { width: 26px; height: 4px; border-radius: 2px; }
.sf-dots-bars .sf-slider-dot.is-active { width: 40px; }
.sf-dots-numbers .sf-slider-dots { gap: 2px; align-items: baseline; }
.sf-dots-numbers .sf-slider-dot {
  width: auto; height: auto; padding: 2px 5px; border-radius: 6px; background: transparent;
  color: rgba(255, 255, 255, .7); font: inherit; font-size: 13px; font-weight: 700;
  text-shadow: 0 1px 5px rgba(0, 0, 0, .6);
}
.sf-dots-numbers .sf-slider-dot.is-active { width: auto; background: transparent; color: #fff; }
.sf-dots-numbers .sf-dot-n { display: inline; }
.sf-dots-numbers .sf-dot-total {
  display: inline; margin-left: 2px; font-size: 13px; font-weight: 700;
  color: rgba(255, 255, 255, .7); text-shadow: 0 1px 5px rgba(0, 0, 0, .6);
}

/* How fast a change is. Only the fade transition has a duration to vary — "slide across" and "cut"
   are handled by the active-slide swap itself. */
.sf-slider-sp-slow .sf-slide { transition-duration: .8s; }
.sf-slider-sp-fast .sf-slide { transition-duration: .22s; }
.sf-slider-x-none .sf-slide { transition: none; }

/* An add-on's quantity is derived from what it hangs off, so it reads as text where an editable
   box would sit — same column, same alignment, no control that would lie about being editable. */
.sf-cart-qty-fixed { font-size: 14px; color: var(--sf-muted); white-space: nowrap; }

/* Add-on dropdowns sit in the buy form beside the variant picker, so they inherit .sf-field's
   layout; this only stops a long option label stretching the form wider than its column. */
.sf-addon select { max-width: 100%; }

/* ── Merchandising (MODULE_14) ─────────────────────────────────────────────────────────────────
   Three shopper-facing blocks: the upgrade card above the buy button, the two strips under the
   reviews, and the cart's cross-sell. No JS and no inline styles anywhere in them — the storefront
   CSP is default-src 'self'. */

/* Upgrades. Compact rows rather than cards: this sits inside the product column, above the buy
   button, and anything bigger would push the button below the fold on a phone. */
.sf-upsell { margin: 18px 0; padding: 14px 16px; border: 1px solid var(--sf-border); border-radius: var(--sf-radius); background: var(--sf-surface); }
.sf-upsell-h { margin: 0 0 10px; font-size: 14px; font-weight: 700; letter-spacing: .01em; }
.sf-upsell-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.sf-upsell-link { display: flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; border-radius: 10px; padding: 4px; }
.sf-upsell-link:hover { background: var(--sf-bg); }
.sf-upsell-thumb { flex: 0 0 auto; width: 48px; height: 48px; display: grid; place-items: center; overflow: hidden; border-radius: 8px; background: var(--sf-accent); color: var(--sf-brand); font-size: 18px; font-weight: 800; }
.sf-upsell-body { display: flex; flex: 1 1 auto; min-width: 0; align-items: baseline; justify-content: space-between; gap: 10px; }
.sf-upsell-title { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sf-upsell-price { flex: 0 0 auto; font-size: 14px; font-weight: 800; }
.sf-upsell-from { font-size: 11.5px; font-weight: 600; color: var(--sf-faint); }

/* ── What's included: a FIXED bundle's contents ──────────────────────────────────────────────
   Deliberately the same shell as .sf-upsell directly above it — a merchant reads one card that
   offers alternatives and one that lists what is in the box, and they should look like siblings
   rather than two unrelated inventions. No thumbnails: this is a manifest, not a set of choices,
   and 48px images would make it compete with the upsell for the same attention. */
.sf-inbox { margin: 18px 0; padding: 14px 16px; border: 1px solid var(--sf-border); border-radius: var(--sf-radius); background: var(--sf-surface); }
.sf-inbox-h { margin: 0 0 10px; font-size: 14px; font-weight: 700; letter-spacing: .01em; }
.sf-inbox-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
.sf-inbox-row { display: flex; align-items: baseline; gap: 10px; font-size: 14px; line-height: 1.45; }
/* Tabular figures so a column of 1x / 2x / 10x lines up on its own edge. */
.sf-inbox-qty { flex: 0 0 auto; min-width: 2.2em; font-weight: 700; color: var(--sf-faint); font-variant-numeric: tabular-nums; }
.sf-inbox-name { min-width: 0; }
.sf-inbox-name a { color: inherit; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: var(--sf-border); }
.sf-inbox-name a:hover { text-decoration-color: currentColor; }


/* Bought-together + related. Full-width strips of the standard product card, separated from the
   reviews above them by the same rule the page uses elsewhere. */
.sf-strip { margin-top: 40px; padding-top: 28px; border-top: 1px solid var(--sf-border); }
.sf-strip-h { margin: 0 0 16px; font-size: 20px; font-weight: 700; }

/* Cart cross-sell. Sits in the lines column under the add-on strip, so the cards are narrower than
   a listing page's — a suggestion should not out-shout the basket it is attached to. */
.sf-xsell-strip { margin-top: 26px; padding-top: 20px; border-top: 1px solid var(--sf-border); }
.sf-xsell-strip-h { margin: 0 0 12px; font-size: 15px; font-weight: 700; }
.sf-xsell-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.sf-xsell-grid .sf-card-body { padding: 10px; }
.sf-xsell-grid .sf-card-title { font-size: 13.5px; }
.sf-xsell-grid .sf-card-price { font-size: 14px; }

/* ── Deals (0090) ───────────────────────────────────────────────────────────────────────────────
   A multibuy is only worth running if the shopper can see it. Three places say so, in the shop's
   own accent so a deal reads as the shop's offer rather than a system message: the offer line under
   the price on a product page, the repriced basket line, and the near-miss nudge. No inline styles
   and no JavaScript anywhere in here — the storefront CSP allows neither. */

/* Product page: what this product is part of, straight under the price. */
.sf-pd-deals { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 0 12px; padding: 0; }
.sf-pd-deal { padding: 3px 10px; border-radius: 999px; background: var(--sf-accent); color: var(--sf-brand); font-size: 13px; font-weight: 700; }

/* Basket line: the deal that repriced it, then what it was against what it now costs. */
.sf-cart-dealtag { align-self: flex-start; padding: 1px 8px; border-radius: 999px; background: var(--sf-accent); color: var(--sf-brand); font-size: 11.5px; font-weight: 700; }
.sf-cart-was { color: var(--sf-faint); font-weight: 400; }
.sf-cart-now { color: var(--sf-brand); }

/* Near miss. Set apart from the lines above it, because it is an invitation rather than a fact
   about the basket. */
.sf-deal-nudges { margin-top: 14px; display: flex; flex-direction: column; gap: 6px; }
.sf-deal-nudge { margin: 0; padding: 9px 12px; border-radius: 8px; background: var(--sf-accent); color: var(--sf-brand); font-size: 13.5px; font-weight: 600; }

/* Summary: which offers made up the multibuy saving on the row above. */
.sf-sum-deals { display: flex; flex-wrap: wrap; gap: 5px; margin: -2px 0 8px; }
.sf-deal-chip { padding: 1px 8px; border-radius: 999px; background: var(--sf-accent); color: var(--sf-brand); font-size: 11.5px; font-weight: 700; }
/* "Checked at checkout" — an offer gated on who is buying. Same quiet note as the coupon's. */
.sf-deal-note { margin: -4px 0 8px; font-size: 12.5px; color: var(--sf-muted, #667085); }
/* Delivery a deal has paid for: the rate it struck out, and which offer did it. */
.sf-ship-was { color: var(--sf-muted, #667085); font-weight: 400; margin-right: 4px; }
.sf-ship-dealtag { display: block; font-size: 11.5px; font-weight: 700; color: var(--sf-brand); }

/* ══ CATALOGUE BLOCKS (0097) ═══════════════════════════════════════════════════════════════════
   The page builder's product grid, product carousel, category grid and review wall. All four show
   RECORDS rather than words a merchant typed, so all four reuse what already renders records —
   .sf-card and .sf-stars — instead of growing a second idea of what a product card looks like.

   No JavaScript and no inline style anywhere below: the storefront runs default-src 'self' with no
   'unsafe-inline'. Every merchant choice that is a closed list becomes a class chosen on the server;
   the two that are numbers on a scale (how many across, how many seconds) become classes ON that
   scale, which is the only mechanism left once inline style is gone. */

/* ── One grid, three blocks ────────────────────────────────────────────────────────────────────
   The merchant picks a column count and the row still has to survive a phone. This does both with
   NO breakpoint: each track's minimum is the LARGER of a readable floor and the merchant's share of
   the row, so a wide container gives exactly the chosen number of columns and a narrow one fits as
   many as will still read — the same behaviour .sf-grid has always had on the listing, with the
   count made a setting. --sf-cgap already defers to the shared block gap for the day the design
   track wires --sf-sec-gap up; until then the fallback is the gap this file has always used. */
.sf-cols {
  --sf-cgap: var(--sf-sec-gap, 18px);
  /* THE GAP, SPLIT (0098). Each direction defers to the shared pair, exactly as a per-side padding
     defers to `--sf-sec-pad` — so "item gap 18, then rows 32" means the plain English thing. With
     neither set both resolve to `--sf-cgap` and `gap: X X` is `gap: X`, so a grid nobody has touched
     lays out to the same pixel. The column track calc uses the HORIZONTAL one, because that is the
     space the columns actually have to share. */
  --sf-cgap-x: var(--sf-sec-gap-col, var(--sf-cgap));
  --sf-cgap-y: var(--sf-sec-gap-row, var(--sf-cgap));
  --sf-cmin: 180px;
  --sf-cols: 4;
  display: grid;
  gap: var(--sf-cgap-y) var(--sf-cgap-x);
  /* Vertical alignment (0100). `normal` is the fallback and not `stretch`, because `normal` IS the
     initial value of `align-items` — writing `stretch` here would be a change of behaviour dressed
     up as a default, and grid items whose contents establish their own size do not treat the two
     the same. With this unset, every card grid on three trading shops resolves to what it always
     resolved to. */
  align-items: var(--sf-sec-align-y, normal);
  grid-template-columns: repeat(auto-fill, minmax(max(var(--sf-cmin), calc((100% - (var(--sf-cols) - 1) * var(--sf-cgap-x)) / var(--sf-cols))), 1fr));
}
/* After .sf-cols on purpose: same specificity, so source order is what lets the chosen count win. */
.sf-cols-1 { --sf-cols: 1; } .sf-cols-2 { --sf-cols: 2; } .sf-cols-3 { --sf-cols: 3; }
.sf-cols-4 { --sf-cols: 4; } .sf-cols-5 { --sf-cols: 5; } .sf-cols-6 { --sf-cols: 6; }

.sf-pgrid, .sf-pcar, .sf-catgrid, .sf-revwall { margin: 28px 0; }

/* Heading size (product grid + category grid only — the handoff puts `headFs` on just those two).
   A short list rather than the handoff's pixels, so a block stays in step with its theme. */
/* The pixel override on the Design tab WINS over this scale, and the scale is what it falls back
   to — typing 34 is a more specific answer than picking "large", and a merchant who did both meant
   the number. Same relationship the micro's `text_size` has with the price micro's own `size`. */
.sf-h-small { font-size: var(--sf-sec-head-size, 18px); }
.sf-h-medium { font-size: var(--sf-sec-head-size, 24px); }
.sf-h-large { font-size: var(--sf-sec-head-size, 30px); }

/* Card style. "Plain" takes the card edge away rather than drawing a different card, so a shop can
   switch a row from boxed to bare without the products moving. */
.sf-cards-compact .sf-card-body { padding: 10px; gap: 3px; }
.sf-cards-compact .sf-card-title { font-size: 13.5px; }
.sf-cards-compact .sf-card-price { font-size: 14px; }
.sf-cards-plain .sf-card { border: 0; background: transparent; box-shadow: none; }
.sf-cards-plain .sf-card:hover { transform: none; box-shadow: none; }
.sf-cards-plain .sf-card-body { padding: 10px 0; }
.sf-cards-plain .sf-card-thumb { border-radius: var(--sf-radius); }

/* ── Carousel ──────────────────────────────────────────────────────────────────────────────────
   ONE structure for all three modes: a track holding one card set, or two identical ones when it
   moves. `manual` scrolls the track; the moving modes translate it by exactly one set.

   That "exactly" is why the gap between cards is a MARGIN on each card and not `gap` on the track:
   a gap sits between the two sets as well, so half of it lands inside the -50% and the seam creeps
   a little further out of line on every loop.

   The card width is a CONTAINER query, not a media query. A carousel does not know how wide the
   page is; it knows how wide it is. 100cqi is its own inline size, so "four at a time" means four
   of whatever space this block was given — in a sidebar as much as across a home page — and the
   180px floor is what stops six-across becoming six slivers on a phone. */
.sf-carousel { --sf-cgap: var(--sf-sec-gap, 18px); --sf-cgap-x: var(--sf-sec-gap-col, var(--sf-cgap)); --sf-car-per: 4; container-type: inline-size; }
.sf-carousel.is-continuous, .sf-carousel.is-step { overflow: hidden; }
.sf-carousel-track { display: flex; }
.sf-carousel.is-manual > .sf-carousel-track {
  overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: thin; padding-bottom: 6px;
}
.sf-carousel-set { display: flex; flex: 0 0 auto; }
/* The literal width is the fallback for a browser without container queries — it keeps a usable
   scrolling row rather than collapsing the cards. */
.sf-carousel-set > .sf-card { flex: 0 0 auto; width: 240px; margin-right: var(--sf-cgap-x); scroll-snap-align: start; }
.sf-carousel-set > .sf-card { width: max(180px, calc((100cqi - (var(--sf-car-per) - 1) * var(--sf-cgap-x)) / var(--sf-car-per))); }

@keyframes sf-carousel-run { to { transform: translateX(-50%); } }
.sf-carousel.is-continuous .sf-carousel-track { animation: sf-carousel-run var(--sf-car-secs, 40s) linear infinite; }
/* One page every `interval` seconds: the same keyframe, stepped. steps() and the total duration both
   read the page count, which is why the server has to know it — see the partial. */
.sf-carousel.is-step .sf-carousel-track {
  animation: sf-carousel-run calc(var(--sf-car-secs, 5s) * var(--sf-car-pages, 4)) steps(var(--sf-car-pages, 4)) infinite;
}
/* Pause is a data attribute the stylesheet reads — never a :hover rule written per block, which
   would be one rule per carousel per store. */
.sf-carousel-track[data-sf-pause="1"]:hover { animation-play-state: paused; }

/* Seconds, on the scale the two sliders can actually produce: `speed` is 10–120 in fives, `interval`
   is 2–15 in ones, and a block is in exactly ONE mode — so one scale serves both and neither can
   drift from the other. A value off the scale (which validation cannot produce) simply leaves the
   default in the rules above, which is the honest failure: it still moves, at the default rate. */
.sf-car-secs-2 { --sf-car-secs: 2s; } .sf-car-secs-3 { --sf-car-secs: 3s; } .sf-car-secs-4 { --sf-car-secs: 4s; }
.sf-car-secs-5 { --sf-car-secs: 5s; } .sf-car-secs-6 { --sf-car-secs: 6s; } .sf-car-secs-7 { --sf-car-secs: 7s; }
.sf-car-secs-8 { --sf-car-secs: 8s; } .sf-car-secs-9 { --sf-car-secs: 9s; } .sf-car-secs-10 { --sf-car-secs: 10s; }
.sf-car-secs-11 { --sf-car-secs: 11s; } .sf-car-secs-12 { --sf-car-secs: 12s; } .sf-car-secs-13 { --sf-car-secs: 13s; }
.sf-car-secs-14 { --sf-car-secs: 14s; } .sf-car-secs-15 { --sf-car-secs: 15s; } .sf-car-secs-20 { --sf-car-secs: 20s; }
.sf-car-secs-25 { --sf-car-secs: 25s; } .sf-car-secs-30 { --sf-car-secs: 30s; } .sf-car-secs-35 { --sf-car-secs: 35s; }
.sf-car-secs-40 { --sf-car-secs: 40s; } .sf-car-secs-45 { --sf-car-secs: 45s; } .sf-car-secs-50 { --sf-car-secs: 50s; }
.sf-car-secs-55 { --sf-car-secs: 55s; } .sf-car-secs-60 { --sf-car-secs: 60s; } .sf-car-secs-65 { --sf-car-secs: 65s; }
.sf-car-secs-70 { --sf-car-secs: 70s; } .sf-car-secs-75 { --sf-car-secs: 75s; } .sf-car-secs-80 { --sf-car-secs: 80s; }
.sf-car-secs-85 { --sf-car-secs: 85s; } .sf-car-secs-90 { --sf-car-secs: 90s; } .sf-car-secs-95 { --sf-car-secs: 95s; }
.sf-car-secs-100 { --sf-car-secs: 100s; } .sf-car-secs-105 { --sf-car-secs: 105s; } .sf-car-secs-110 { --sf-car-secs: 110s; }
.sf-car-secs-115 { --sf-car-secs: 115s; } .sf-car-secs-120 { --sf-car-secs: 120s; }
/* Cards visible at once (2–6), and the resulting page count (at most 24 cards ÷ 2 across). */
.sf-car-per-2 { --sf-car-per: 2; } .sf-car-per-3 { --sf-car-per: 3; } .sf-car-per-4 { --sf-car-per: 4; }
.sf-car-per-5 { --sf-car-per: 5; } .sf-car-per-6 { --sf-car-per: 6; }
.sf-car-pages-1 { --sf-car-pages: 1; } .sf-car-pages-2 { --sf-car-pages: 2; } .sf-car-pages-3 { --sf-car-pages: 3; }
.sf-car-pages-4 { --sf-car-pages: 4; } .sf-car-pages-5 { --sf-car-pages: 5; } .sf-car-pages-6 { --sf-car-pages: 6; }
.sf-car-pages-7 { --sf-car-pages: 7; } .sf-car-pages-8 { --sf-car-pages: 8; } .sf-car-pages-9 { --sf-car-pages: 9; }
.sf-car-pages-10 { --sf-car-pages: 10; } .sf-car-pages-11 { --sf-car-pages: 11; } .sf-car-pages-12 { --sf-car-pages: 12; }

/* THE MOVING MODES STOP DEAD, twice, for two different reasons — and both times they become the
   manual row rather than a frozen half-scrolled track, because a row you can swipe is the honest
   still version of a carousel.
     · Reduced motion is a request, and it is not negotiable here: the shared `force_motion` escape
       hatch is declared-only in the schema, so it does not reach this and must not.
     · A container narrower than about two pages cannot show a page at a time — stepping four cards
       while two are visible means half the products are never seen. Swiping is what a phone wanted
       anyway.
   Both need the same specificity as the rules they override, and follow them in source order. */
@media (prefers-reduced-motion: reduce) {
  .sf-carousel.is-continuous .sf-carousel-track,
  .sf-carousel.is-step .sf-carousel-track {
    animation: none; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: thin;
  }
  .sf-carousel-clone { display: none; }
}
@container (max-width: 700px) {
  .sf-carousel.is-continuous .sf-carousel-track,
  .sf-carousel.is-step .sf-carousel-track {
    animation: none; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: thin;
  }
  .sf-carousel-clone { display: none; }
}

/* ── Category grid ─────────────────────────────────────────────────────────────────────────────
   A way IN to the range, so the card is mostly a name and a shape. `collections` has no image
   column yet, so an automatic card is a name on the shop's own accent tint and only a hand-written
   row can carry a photograph — the two have to sit level in the same row, which is what the fixed
   aspect ratio is for. */
.sf-catgrid .sf-cols { --sf-cmin: 140px; }
.sf-catcard {
  position: relative; display: flex; align-items: center; justify-content: flex-start;
  overflow: hidden; padding: 12px 14px; text-align: left;
  border-radius: var(--sf-radius); background: var(--sf-accent); color: var(--sf-brand);
  transition: transform .12s ease, box-shadow .12s ease;
}
a.sf-catcard:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(20, 24, 40, .12); }
.sf-ratio-square { aspect-ratio: 1 / 1; }
.sf-ratio-landscape { aspect-ratio: 4 / 3; }
.sf-ratio-portrait { aspect-ratio: 3 / 4; }
.sf-ratio-wide { aspect-ratio: 16 / 9; }
/* ── PC-5: the round tile, and the line that scrolls ─────────────────────────────────────────
   A circle is a SQUARE with a radius — same aspect ratio, same markup, one more class — which is
   why this is a shape option rather than a block of its own. `overflow: hidden` is what makes the
   photograph inside follow the curve; without it the picture squares off the corners it is meant
   to fill. */
.sf-ratio-round { aspect-ratio: 1 / 1; border-radius: 50%; overflow: hidden; }
/* A round card's name sits UNDER it, not across it: a label centred in a circle over a photograph
   is the least readable arrangement of the two, and the measured references all put it below. */
.sf-ratio-round .sf-catcard-label { position: absolute; bottom: -1.6em; width: 100%; text-align: center; }
.sf-cat-strip:has(.sf-ratio-round) { padding-bottom: 1.8em; }

/* THE STRIP. The carousel's own manual-scroll vocabulary, reused rather than reinvented: the same
   snap, the same thin scrollbar, cards that refuse to shrink. One gesture, one implementation.
   Overriding the grid the `.sf-cols-*` classes set, which is why this is written as flex. */
.sf-cat-strip {
  display: flex; flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
  scroll-snap-type: x proximity; scrollbar-width: thin; padding-bottom: 6px;
}
.sf-cat-strip > .sf-catcard { flex: 0 0 auto; width: 132px; scroll-snap-align: start; }
/* The keyboard's focus ring on the scroller itself — it is a tab stop by design (a scrollable
   region a keyboard cannot reach is a part of the shop only a mouse can see) and a tab stop with
   no visible focus is worse than none. */
.sf-cat-strip:focus-visible { outline: 2px solid var(--sf-brand); outline-offset: 3px; border-radius: 6px; }
.sf-catcard-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* A photo card puts the name at the foot over a scrim — a layer between the picture and the words,
   not a filter on the picture, so the photograph stays the photograph. */
.sf-catcard.has-photo { color: #fff; align-items: flex-end; }
.sf-catcard.has-photo::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(rgba(0, 0, 0, 0) 45%, rgba(0, 0, 0, .62));
}
.sf-catcard-label { position: relative; z-index: 1; font-size: 15px; font-weight: 700; line-height: 1.3; }
.sf-cat-center .sf-catcard { justify-content: center; text-align: center; }

/* ── Review wall ───────────────────────────────────────────────────────────────────────────────
   The shop's own approved reviews, anywhere on the site. Tokenised rather than the fixed greys the
   product page's review list still uses, so this one follows the store's theme and its dark mode. */
.sf-revwall .sf-cols { --sf-cmin: 250px; }
/* THE SHOP'S OVERALL SCORE, above the wall. One line, and it sits between the heading and the cards
   rather than inside one, because it is a statement about the shop and every card below it is a
   statement by one customer.

   IT ADDS NO COLOUR OF ITS OWN. The glyphs are `.sf-stars` (:299), the same row every review card
   below already draws, so the shop's gold is the shop's gold in one place. That is also why the
   score is not merchant-tintable: a star is a non-text graphic conveying a rating (WCAG 1.4.11 at
   3:1, not 1.4.3 at 4.5:1), and this platform has not built the graphic bar that would judge it —
   see the note on `rating_stars` in SectionMicros for the full argument. */
.sf-revagg {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px;
  margin: -6px 0 16px; font-size: 14.5px; color: var(--sf-muted);
}
.sf-revagg-score { color: var(--sf-ink); font-size: 16px; }
.sf-revcard {
  display: flex; flex-direction: column; gap: 7px; margin: 0; padding: 16px 18px;
  border: 1px solid var(--sf-border); border-radius: var(--sf-radius);
  background: var(--sf-surface); box-shadow: var(--sf-shadow);
}
.sf-revcard-title { font-size: 15px; }
.sf-revcard-body { margin: 0; font-size: 14.5px; line-height: var(--sf-sec-text-lh, 1.6); color: var(--sf-ink); }
.sf-revcard-by { font-size: 12.5px; color: var(--sf-muted); }
.sf-revcard-by cite { font-style: normal; font-weight: 600; color: var(--sf-ink); }
.sf-revcard-reply {
  margin-top: 2px; padding: 9px 12px; border-radius: 8px;
  background: var(--sf-surface); font-size: 13px; color: var(--sf-muted);
}

/* ── MICRO-WIDGETS ─────────────────────────────────────────────────────────────────────────────
   The small elements a merchant drops INSIDE a block: a trust chip under a hero, a coupon code
   beside a promise, the product card's own price moved into the corner of its picture.

   THE ONE RULE THIS WHOLE SECTION SERVES. The storefront runs `default-src 'self'` with no
   `style-src` and no nonce, so a `style=""` attribute is dropped by the browser and the control
   silently does nothing. So every value a merchant can choose arrives one of exactly two ways:

     · a class the SERVER chose from a closed option list — `.sf-mi-place-*`, `.sf-mi-align-*`,
       `.sf-mi-w-*`, `.sf-mi-st-*`, `.sf-mi-ic-*`, `.sf-mi-sz-*`. See MicroRender::rows().
     · a custom property in the block's own scoped CSS, written by MicroRender::css() into the
       store's generated /theme.css: `.sf-sec-12 .sf-mi-1 { --sf-mi-colour: #0e9f6e }`.

   EVERY PROPERTY BELOW IS READ WITH A FALLBACK. That is what makes "blank" mean "leave it to the
   theme" rather than "zero" — every shared design default in the schema is deliberately blank, and
   a rule that read `var(--sf-mi-m-top)` bare would collapse the margin of every micro nobody had
   nudged. */
.sf-mi {
  display: inline-flex; align-items: center; gap: 7px;
  margin-top: var(--sf-mi-m-top, 0px);
  translate: var(--sf-mi-off-x, 0px) var(--sf-mi-off-y, 0px);
  /* THE ELEMENT'S OWN BOX. Every kind that draws a real chip sets its own padding, radius and edge
     further down, and each of those declarations now reads the same property with ITS value as the
     fallback — so a merchant nudging "padding" on a coupon chip moves the chip's actual 6px 12px
     rather than losing to it. Here the fallback is the shape a bare .sf-mi has always had: none. */
  padding: var(--sf-mi-pad-y, 0px) var(--sf-mi-pad-x, 0px);
  border: var(--sf-mi-bd-w, 0px) var(--sf-mi-bd-style, solid) var(--sf-mi-bd-colour, transparent);
  border-radius: var(--sf-mi-bd-radius, 0px);
  box-shadow: var(--sf-mi-shadow, none);
  font-size: var(--sf-mi-text-size, 13.5px); font-weight: var(--sf-mi-text-weight, inherit);
  line-height: 1.35;
}

/* The two closed lists on a micro, as classes — the same split as a block's, in the micro's own
   namespace so neither can ever reach the other. The shadow literals are ThemeTokens::SHADOWS,
   pinned by the same test that pins the block's. */
.sf-mi-bds-solid { --sf-mi-bd-style: solid; }
.sf-mi-bds-dashed { --sf-mi-bd-style: dashed; }
.sf-mi-bds-dotted { --sf-mi-bd-style: dotted; }
.sf-mi-shadow-subtle { --sf-mi-shadow: 0 1px 3px rgba(20,24,40,.08); }
.sf-mi-shadow-soft { --sf-mi-shadow: 0 2px 8px rgba(20,24,40,.10); }
.sf-mi-shadow-lifted { --sf-mi-shadow: 0 6px 18px rgba(20,24,40,.14); }
.sf-mi-glyph { flex: 0 0 auto; }
.sf-mi-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* Width and horizontal alignment. `auto` is the default and says "only as wide as it needs". */
.sf-mi-w-full { display: flex; width: 100%; }
.sf-mi-align-left { justify-content: flex-start; text-align: left; }
.sf-mi-align-center { justify-content: center; text-align: center; }
.sf-mi-align-right { justify-content: flex-end; text-align: right; }
.sf-mi-w-auto.sf-mi-align-center { margin-left: auto; margin-right: auto; }
.sf-mi-w-auto.sf-mi-align-right { margin-left: auto; }

/* ORDER, not position. A flow micro sits inside a flex column and is ordered into it: `above` is
   -1, which puts it in front of everything the block or the card body already draws, and `under` is
   3, which puts it after. Moving an element by re-ordering it costs no layout of its own and cannot
   overlap anything — which is why the non-overlay placements are not absolute. */
.sf-mi-flow.sf-mi-place-above { order: -1; }
.sf-mi-flow.sf-mi-place-under { order: 3; }

/* THE OVERLAY THREE. Sized to their content and pinned to a corner — never `inset: 0`, because a
   chip that covered the picture would swallow the click and the card is one big link. The
   `pointer-events: none` is the belt to that braces: a chip is decoration, and a shopper pressing a
   price should still land on the product. */
.sf-mi-over {
  position: absolute; z-index: 2; pointer-events: none;
  max-width: calc(100% - 12px);
}
.sf-mi-place-overlay-top-left { top: 6px; left: 6px; }
.sf-mi-place-overlay-top-right { top: 6px; right: 6px; }
.sf-mi-place-overlay-bottom { left: 6px; right: 6px; bottom: 6px; max-width: none; }
.sf-mi-place-overlay-bottom.sf-mi-w-auto { right: auto; }

/* The block's own overlay layer. `inset: 0` is safe HERE and only here because the layer itself is
   click-through — the chips inside it take their events back one at a time. */
.sf-block.sf-has-mi { position: relative; }
.sf-mi-band { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.sf-mi-band-above { margin: 0 0 12px; }
.sf-mi-band-under { margin: 12px 0 0; }
.sf-mi-band-over { position: absolute; inset: 0; pointer-events: none; }
.sf-mi-band-over > .sf-mi { pointer-events: auto; }

/* ── The card-level pair ─────────────────────────────────────────────────────────────────────
   Neither of these is a new element. `sf-card-price` and the badge set are what the card already
   draws; the micro moves them and tints them. */
/* The card price is the one micro that is not a chip of its own — it IS the card's price element,
   wearing the micro classes. So the shared weight has to be read HERE, at two-class specificity,
   or `.sf-mi { font-weight: … }` further up would take the 800 off every price a merchant moved.
   Its font-size is deliberately left alone: .sf-mi has set that since micros shipped. */
.sf-card-price.sf-mi { display: inline-flex; font-weight: var(--sf-mi-text-weight, 800); }
.sf-card-price.sf-mi-over {
  padding: var(--sf-mi-pad-y, 4px) var(--sf-mi-pad-x, 9px); border-radius: var(--sf-mi-bd-radius, 999px);
  background: var(--sf-mi-colour, rgba(18, 21, 28, .78)); color: #fff;
  font-size: var(--sf-mi-text-size, 14px); box-shadow: var(--sf-mi-shadow, 0 1px 4px rgba(16, 22, 40, .22));
}
/* The card's own 4px is the FALLBACK, not something the micro throws away: `margin-top` on .sf-mi
   would otherwise collapse the price against the title for every merchant who never touched the
   "space above" slider. */
.sf-card-price.sf-mi-flow { margin-top: var(--sf-mi-m-top, 4px); color: var(--sf-mi-colour, inherit); }
.sf-card-price.sf-mi-sz-small { font-size: var(--sf-mi-text-size, 13px); }
.sf-card-price.sf-mi-sz-medium { font-size: var(--sf-mi-text-size, 16px); }
.sf-card-price.sf-mi-sz-large { font-size: var(--sf-mi-text-size, 19px); }
.sf-card-price.sf-mi-over .sf-card-was, .sf-card-price.sf-mi-over .sf-card-from { color: rgba(255, 255, 255, .74); }
.sf-card-save { font-size: 12px; font-weight: 700; color: var(--sf-mi-colour, #037f4c); }
.sf-card-price.sf-mi-over .sf-card-save { color: #fff; }

/* A badge micro in the FLOW takes the badges out of the picture's corners, so the absolute
   positioning `badge-pos` carries has to come off — inside the card body it would float the chip
   loose over the words. */
.sf-mi-k-badge-chip { gap: 5px; flex-wrap: wrap; }
.sf-mi-flow .badge-pos { position: static; }
/* TINTING A BADGE WITHOUT DESTROYING IT WHEN NOTHING IS CHOSEN. A badge's colour is its own — one
   of fourteen swatch classes the badge engine picked per product — and `--sf-mi-colour` is emitted
   ONLY when the merchant has chosen one. `background: var(--sf-mi-colour, )` would be an empty
   fallback, which is an invalid declaration and would strip every badge back to no colour at all.
   A flat gradient layered OVER the swatch is the version that degrades correctly: transparent when
   nothing is set, an opaque flood when something is. */
.sf-mi-k-badge-chip .badge-chip {
  background-image: linear-gradient(var(--sf-mi-colour, transparent), var(--sf-mi-colour, transparent));
}

/* ── The two card kinds that DISPLAY a record ────────────────────────────────────────────────
   Stars and a stock line. Neither is a control and neither can be: the whole card is one `<a>`, so
   anything clickable inside it either fights the link or, in the nested-anchor case, closes it. The
   three kinds that would have to ACT — add to basket, quantity, wishlist — are inert for that
   reason and have no rules here at all.

   Both reuse what the shop already has: `.sf-stars` is the review wall's own star row, so a rating
   on a card and a rating on the reviews block are the same five glyphs in the same gold. */
.sf-mi-k-rating-stars { gap: 5px; }
/* The chip's own colour control tints the LIT stars only. An unlit star is the track the rating is
   read against, and colouring both would leave a five-star row that says nothing. */
.sf-mi-k-rating-stars .sf-stars { font-size: var(--sf-mi-text-size, 13px); }
.sf-mi-k-rating-stars .sf-stars span.on { color: var(--sf-mi-colour, #e8a13a); }
.sf-mi-k-rating-stars .sf-mi-label { font-size: var(--sf-mi-text-size, 12px); color: var(--sf-faint); font-weight: var(--sf-mi-text-weight, 600); }
/* Over a photograph the gold holds up but the "(12)" does not, so the whole chip gets a plate —
   the same treatment, and the same fallbacks, as an overlay price. */
.sf-mi-k-rating-stars.sf-mi-over {
  padding: var(--sf-mi-pad-y, 3px) var(--sf-mi-pad-x, 8px); border-radius: var(--sf-mi-bd-radius, 999px);
  background: rgba(18, 21, 28, .72);
}
.sf-mi-k-rating-stars.sf-mi-over .sf-mi-label { color: rgba(255, 255, 255, .82); }

.sf-mi-k-stock-meter { gap: 6px; font-weight: var(--sf-mi-text-weight, 600); color: var(--sf-mi-colour, var(--sf-muted)); }
.sf-mi-k-stock-meter.sf-mi-over {
  padding: var(--sf-mi-pad-y, 3px) var(--sf-mi-pad-x, 8px); border-radius: var(--sf-mi-bd-radius, 999px);
  background: rgba(18, 21, 28, .72); color: var(--sf-mi-colour, #fff);
}
/* THE BAR. Its length is a CLASS, not a property, and that is forced rather than chosen: a block's
   generated CSS is written once per block, so a custom property would give every card in the grid
   the same bar — and a style attribute is dropped by this CSP. Ten steps, snapped in the template,
   which is what keeps the list closed. */
.sf-mi-meter {
  flex: 0 0 auto; width: 46px; height: 5px; border-radius: 999px; overflow: hidden;
  background: var(--sf-border);
}
.sf-mi-meter-fill { display: block; height: 100%; border-radius: inherit; background: var(--sf-mi-colour, #d0342c); }
.sf-mi-fill-10 { width: 10%; }
.sf-mi-fill-20 { width: 20%; }
.sf-mi-fill-30 { width: 30%; }
.sf-mi-fill-40 { width: 40%; }
.sf-mi-fill-50 { width: 50%; }
.sf-mi-fill-60 { width: 60%; }
.sf-mi-fill-70 { width: 70%; }
.sf-mi-fill-80 { width: 80%; }
.sf-mi-fill-90 { width: 90%; }
.sf-mi-fill-100 { width: 100%; }

/* THE OFFER CHIP — "3 for £10" on a card. The deal engine's own sentence, in a chip.

   ONE MEANING FOR `--sf-mi-colour`, IN EVERY PLACEMENT: it is the PLATE, and the words on it are the
   theme's own ink. That is not a simplification for its own sake — it is what makes the readability
   note honest. ColourContrast switches on ONE sibling field, so a kind whose colour depended on both
   a style AND a placement would have combinations it measured against the wrong surface and reported
   with a confident number. See the note on the field in SectionMicros.

   AND THE PLATE IS OPAQUE OVER A PICTURE, which is the same reason the overlay price wears a pill:
   the three `overlay_*` placements sit on a product photograph nobody here has seen, and a
   semi-transparent plate would make the effective background 28% of an unknown image. Opaque means
   the pair is fully known, so this chip has a real contrast reading everywhere the stock meter has
   to say it cannot give one.

   Several offers on one product are several chips — the schema caps it at three and the card at
   `max_lines` — so the gap is what keeps them from reading as one long sentence. */
.sf-mi-k-deal-chip {
  padding: var(--sf-mi-pad-y, 2px) var(--sf-mi-pad-x, 8px); border-radius: var(--sf-mi-bd-radius, 999px);
  font-size: var(--sf-mi-text-size, 12px); font-weight: var(--sf-mi-text-weight, 700);
  background: var(--sf-mi-colour, var(--sf-accent)); color: var(--sf-ink);
}
/* A second chip on the same card needs air between them; the first needs none, so the flow margin
   stays the merchant's `m_top` (0 by default) rather than being spent here. */
.sf-mi-k-deal-chip + .sf-mi-k-deal-chip { margin-left: 5px; }
/* In the body they sit on their own line under everything else, like the stock line. `order` is what
   `sf-mi-place-under` already sets; this only stops a long offer stretching the card. */
.sf-mi-flow.sf-mi-k-deal-chip { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ── The block-level five ────────────────────────────────────────────────────────────────────
   `--sf-mi-colour` is blank until a merchant picks a swatch, so each rule falls back to the theme's
   own colour and an untouched micro looks like the rest of the shop. */
.sf-mi-k-cart-button {
  padding: var(--sf-mi-pad-y, 9px) var(--sf-mi-pad-x, 16px); border-radius: var(--sf-mi-bd-radius, 999px);
  font-weight: var(--sf-mi-text-weight, 700);
  background: var(--sf-mi-colour, var(--sf-brand)); color: var(--sf-brand-ink);
}
.sf-mi-k-cart-button.sf-mi-st-outline {
  background: transparent; color: var(--sf-mi-colour, var(--sf-brand));
  border: var(--sf-mi-bd-w, 1.5px) var(--sf-mi-bd-style, solid) var(--sf-mi-bd-colour, var(--sf-mi-colour, var(--sf-brand)));
}
.sf-mi-k-cart-button.sf-mi-st-square { border-radius: var(--sf-mi-bd-radius, 8px); }
.sf-mi-k-cart-button.sf-mi-st-icon { padding: var(--sf-mi-pad-y, 9px) var(--sf-mi-pad-x, 9px); }
.sf-mi-k-cart-button.sf-mi-st-icon .sf-mi-label { display: none; }
.sf-mi-k-cart-button:hover { filter: brightness(1.06); }

.sf-mi-k-search-box { width: var(--sf-mi-width, 100%); max-width: 100%; gap: 0; }
.sf-mi-input {
  flex: 1 1 auto; min-width: 0; padding: 9px 12px; font: inherit; color: var(--sf-ink);
  border: 1px solid var(--sf-border); border-right: 0; border-radius: 8px 0 0 8px; background: var(--sf-surface);
}
.sf-mi-go {
  display: inline-flex; align-items: center; padding: 9px 12px; cursor: pointer;
  border: 1px solid var(--sf-mi-colour, var(--sf-brand)); border-radius: 0 8px 8px 0;
  background: var(--sf-mi-colour, var(--sf-brand)); color: var(--sf-brand-ink);
}

.sf-mi-k-trust-chip {
  padding: var(--sf-mi-pad-y, 7px) var(--sf-mi-pad-x, 12px); border-radius: var(--sf-mi-bd-radius, 999px);
  font-weight: var(--sf-mi-text-weight, 600);
  background: var(--sf-accent); color: var(--sf-mi-colour, var(--sf-ink));
}
.sf-mi-k-countdown {
  padding: var(--sf-mi-pad-y, 7px) var(--sf-mi-pad-x, 12px); border-radius: var(--sf-mi-bd-radius, 999px);
  font-weight: var(--sf-mi-text-weight, 700);
  background: var(--sf-surface);
  border: var(--sf-mi-bd-w, 1px) var(--sf-mi-bd-style, solid) var(--sf-mi-bd-colour, var(--sf-border));
  color: var(--sf-mi-colour, var(--sf-ink));
}
.sf-mi-clock { font-variant-numeric: tabular-nums; }
.sf-mi-k-coupon-chip {
  padding: var(--sf-mi-pad-y, 6px) var(--sf-mi-pad-x, 12px); border-radius: var(--sf-mi-bd-radius, 8px);
  border: var(--sf-mi-bd-w, 1px) var(--sf-mi-bd-style, dashed) var(--sf-mi-bd-colour, var(--sf-mi-colour, var(--sf-brand)));
  background: var(--sf-accent); color: var(--sf-ink);
}
.sf-mi-code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; letter-spacing: .04em;
  color: var(--sf-mi-colour, var(--sf-brand));
}

/* Accessible-name-only text. The search box's label and its submit button both need a name a screen
   reader reads and a sighted shopper does not: a magnifying glass with no name is a button nobody
   using assistive technology can identify. */
.sf-sr {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* A phone is 375px wide, and the whole point of an overlay chip is that it sits ON the picture. At
   this width a full-bleed bottom chip is most of a card, so the corner chips shrink and the bottom
   one stops trying to hold a "was" price as well. */
@media (max-width: 480px) {
  .sf-mi { font-size: var(--sf-mi-text-size, 12.5px); gap: 5px; }
  .sf-mi-over { max-width: calc(100% - 10px); }
  .sf-card-price.sf-mi-over { padding: var(--sf-mi-pad-y, 3px) var(--sf-mi-pad-x, 7px); font-size: var(--sf-mi-text-size, 12.5px); }
  .sf-card-price.sf-mi-sz-large { font-size: var(--sf-mi-text-size, 15.5px); }
  .sf-mi-k-cart-button, .sf-mi-k-trust-chip, .sf-mi-k-countdown { padding: 7px 11px; }
  /* A card is about 165px wide in a two-column phone grid, and an overlay chip sits ON the picture.
     A 46px bar plus a plate is most of that corner, so the bar comes in — the words behind it are
     `sf-sr` and unaffected, which is the point of carrying them separately. */
  .sf-mi-meter { width: 34px; }
  .sf-mi-k-rating-stars .sf-stars { font-size: var(--sf-mi-text-size, 12px); }
}

/* ══ MOTION (0098) ══════════════════════════════════════════════════════════════════════════════
   The entrance animations and hover effects the Motion tab has been saving all along.

   ── THE ANIMATION RUNS WITHOUT JAVASCRIPT. THE SCRIPT ONLY DELAYS IT ─────────────────────────
   Everything below animates on page load. `motion.js` puts `sf-motion` on <html>, and the only
   thing that class does is hold an entrance at its first frame until the block is scrolled to.
   Read the two halves in that order and the safety property falls out: with no script, no class;
   with no class, no waiting rule; with no waiting rule, every block plays once and is visible. A
   block that is invisible because a request failed is a shop that lost a sale, and there is no
   arrangement of these rules that can produce one.

   ── WHY `backwards` AND NOT `both` ───────────────────────────────────────────────────────────
   `both` would keep the last keyframe applied forever, and the last keyframe of every entrance
   here sets `transform`. A block with an entrance AND a hover effect would then never lift: the
   finished animation would out-rank the hover rule for the rest of the visit. `backwards` holds
   the FIRST keyframe through the delay — which is the whole reason fill mode is here — and lets go
   the moment the animation ends, handing `transform` back to :hover. The `to` keyframe is left
   implicit for the same reason: it means "whatever this element already was".

   ── WHAT CARRIES WHAT ────────────────────────────────────────────────────────────────────────
   Every value is a class the SERVER chose from a closed list, or a custom property in the store's
   generated stylesheet — never a style attribute, which this CSP drops. See SectionMotion for the
   one place a declaration is not followed literally and why: a block's duration and delay ride a
   class that sets the property the schema named, because the generated stylesheet's cache stamp
   cannot see them change. A micro's pair are real properties, because it can. */

/* The five entrances. Named for the codebase, not for the handoff's prototype (`omFadeUp` became
   this the same way `omCarousel` became `sf-carousel-run`), and kept clear of `.sf-slide-left`,
   which is a slideshow's text alignment and nothing to do with movement. Geometry is the handoff's:
   18px up, 28px across, 0.94 in. */
@keyframes sf-enter-fade { from { opacity: 0; } }
@keyframes sf-enter-fade-up { from { opacity: 0; transform: translateY(18px); } }
@keyframes sf-enter-left { from { opacity: 0; transform: translateX(-28px); } }
@keyframes sf-enter-right { from { opacity: 0; transform: translateX(28px); } }
@keyframes sf-enter-zoom { from { opacity: 0; transform: scale(.94); } }

/* ── A BLOCK'S ENTRANCE ─────────────────────────────────────────────────────────────────────── */
/* `sf-anim` is the derived marker: this block itself arrives. It carries the timing; the value
   class carries the keyframe. Both are on the same element, so a block with no entrance has no
   animation-name and this rule does nothing at all. */
.sf-anim {
  animation-duration: var(--sf-sec-anim-dur, 600ms);
  animation-delay: var(--sf-sec-anim-delay, 0ms);
  animation-timing-function: cubic-bezier(.16, 1, .3, 1);
  animation-fill-mode: backwards;
}
.sf-anim-fade { animation-name: sf-enter-fade; }
.sf-anim-fade-up { animation-name: sf-enter-fade-up; }
.sf-anim-slide-left { animation-name: sf-enter-left; }
.sf-anim-slide-right { animation-name: sf-enter-right; }
.sf-anim-zoom { animation-name: sf-enter-zoom; }

/* The four easings, the handoff's curves exactly. Same specificity as the rule above and after it
   in source order, which is what lets a chosen curve replace the default one. `inherit` picks none
   of them and gets the default, which is what "theme default" means.

   `sf-ease-ease-out` reads like a stutter and is not a typo: the prefix is the schema's (`sf-ease-`)
   and the value is the schema's (`ease_out`), and a class name assembled from the two is the only
   kind that cannot drift from the control that sets it. Renaming either end here would be this
   file quietly disagreeing with SectionTypes. */
.sf-ease-ease-out { animation-timing-function: cubic-bezier(.16, 1, .3, 1); }
.sf-ease-ease-in-out { animation-timing-function: cubic-bezier(.4, 0, .2, 1); }
.sf-ease-spring { animation-timing-function: cubic-bezier(.34, 1.56, .64, 1); }
.sf-ease-linear { animation-timing-function: linear; }

/* ── A MICRO'S ENTRANCE, AND HOW IT AVOIDS FIGHTING ITS BLOCK ───────────────────────────────────
   Same five keyframes, its own timing, and one rule that decides what happens when both animate:
   INSIDE a block that is arriving, a chip's delay is measured from the moment the block has
   LANDED — the block's delay plus the block's duration plus the chip's own. Both properties
   inherit, so the arithmetic is available on the chip without anyone passing it down.

   That is not a flourish, it is the only sane resolution. Nested transforms multiply: a block
   rising 18px with a chip rising 18px inside it moves the chip 36px, and both would start on the
   same trigger at the same instant. Staggered, the block lands and then its chips do — which is
   exactly what the schema's own help text promises the delay is for, without the merchant having
   to work the number out. A chip in a block that does NOT animate keeps its own delay from the
   trigger, because there is nothing to wait for. */
.sf-mi-anim {
  animation-duration: var(--sf-mi-anim-dur, 450ms);
  animation-delay: var(--sf-mi-anim-delay, 0ms);
  animation-timing-function: cubic-bezier(.16, 1, .3, 1);
  animation-fill-mode: backwards;
}
.sf-anim .sf-mi-anim {
  animation-delay: calc(var(--sf-sec-anim-delay, 0ms) + var(--sf-sec-anim-dur, 600ms) + var(--sf-mi-anim-delay, 0ms));
}
.sf-mi-anim-fade { animation-name: sf-enter-fade; }
.sf-mi-anim-fade-up { animation-name: sf-enter-fade-up; }
.sf-mi-anim-slide-left { animation-name: sf-enter-left; }
.sf-mi-anim-slide-right { animation-name: sf-enter-right; }
.sf-mi-anim-zoom { animation-name: sf-enter-zoom; }
.sf-mi-ease-ease-out { animation-timing-function: cubic-bezier(.16, 1, .3, 1); }
.sf-mi-ease-ease-in-out { animation-timing-function: cubic-bezier(.4, 0, .2, 1); }
.sf-mi-ease-spring { animation-timing-function: cubic-bezier(.34, 1.56, .64, 1); }
.sf-mi-ease-linear { animation-timing-function: linear; }

/* ── WAIT YOUR TURN — the only rules that hide anything, and the only ones the script switches on ─
   `sf-motion` is added by motion.js before the first animated block is parsed; `sf-in` is added by
   its observer when the block reaches the viewport. Between the two the block sits at its first
   frame with no animation NAME at all, which is what lets `anim_repeat` genuinely replay: taking
   the name away and giving it back restarts the animation, where pausing it would only resume a
   finished one. `opacity: 0` is what stands in for the first frame while the name is gone.

   A chip is gated on ITS BLOCK, never on itself. One observer per block, and a chip clipped inside
   a carousel — which the viewport can never see — still arrives when the block it lives in does. */
html.sf-motion .sf-anim:not(.sf-in) { animation-name: none; opacity: 0; }
html.sf-motion .sf-anim-host:not(.sf-in) .sf-mi-anim { animation-name: none; opacity: 0; }

/* A 28px slide is 28px of the block hanging past the right edge for as long as it runs, and on a
   viewport-width block that is a horizontal scrollbar appearing and vanishing — layout shift by
   another name. `clip` rather than `hidden` because `hidden` would make the viewport a scroll
   container and break every `position: sticky` on the page. Matched by `:has()` so it costs a shop
   with no horizontal entrance exactly nothing, and a browser without `:has()` gets the transient
   scrollbar rather than a broken page. */
body:has(.sf-anim-slide-left, .sf-anim-slide-right) { overflow-x: clip; }

/* ── HOVER ──────────────────────────────────────────────────────────────────────────────────────
   FIXED RULES CHOSEN BY A CLASS, never a rule generated per block with the merchant's numbers
   written into it — the same refusal the carousel's pause makes. Five options, one of which is
   "none" and emits nothing, so what the stylesheet holds is four rules for the whole fleet rather
   than one per block per store. */
.sf-hover-lift, .sf-hover-sink, .sf-hover-glow-border {
  transition: transform .18s cubic-bezier(.4, 0, .2, 1), box-shadow .18s cubic-bezier(.4, 0, .2, 1);
}
.sf-hover-lift:hover { transform: translateY(-4px); box-shadow: 0 14px 34px rgba(16, 22, 40, .14); }
.sf-hover-sink:hover { transform: translateY(3px); box-shadow: 0 2px 8px rgba(16, 22, 40, .10); }
.sf-hover-glow-border:hover { box-shadow: 0 0 0 2px var(--sf-brand), 0 10px 30px rgba(16, 22, 40, .10); }
/* The picture moves, not the block. Every wrapper that holds one already clips except the banner's,
   which is why that single line is here — a 5% scale with nothing to clip it overhangs its corner
   radius and reads as a rendering fault. */
.sf-hover-zoom-image img { transition: transform .3s cubic-bezier(.4, 0, .2, 1); }
.sf-hover-zoom-image:hover img { transform: scale(1.05); }
.sf-hover-zoom-image .sf-banner-media { overflow: clip; border-radius: var(--sf-radius); }

.sf-mi-hover-lift, .sf-mi-hover-sink, .sf-mi-hover-glow-border {
  transition: transform .18s cubic-bezier(.4, 0, .2, 1), box-shadow .18s cubic-bezier(.4, 0, .2, 1);
}
.sf-mi-hover-lift:hover { transform: translateY(-3px); box-shadow: 0 8px 20px rgba(16, 22, 40, .16); }
.sf-mi-hover-sink:hover { transform: translateY(2px); }
.sf-mi-hover-glow-border:hover { box-shadow: 0 0 0 2px var(--sf-mi-colour, var(--sf-brand)); }

/* ── A BLOCK'S DURATION AND DELAY, as the class that sets the declared property ──────────────────
   The schema's own scales: 100-2000ms in fifties, and 0-1200ms in fifties. Generated rather than
   hand-typed, and SNAPPED to the step on the way out (SectionMotion::stepped) so the class a block
   asks for is always one that exists — a missing class here is not a missing rule, it is an
   animation quietly running at the default length, which is the failure nobody reports.

   A property rather than a literal `animation-duration` so a merchant's own CSS, and one day the
   generated per-block stylesheet, can set the same thing by name. */
.sf-dur-100 { --sf-sec-anim-dur: 100ms; } .sf-dur-150 { --sf-sec-anim-dur: 150ms; } .sf-dur-200 { --sf-sec-anim-dur: 200ms; } .sf-dur-250 { --sf-sec-anim-dur: 250ms; }
.sf-dur-300 { --sf-sec-anim-dur: 300ms; } .sf-dur-350 { --sf-sec-anim-dur: 350ms; } .sf-dur-400 { --sf-sec-anim-dur: 400ms; } .sf-dur-450 { --sf-sec-anim-dur: 450ms; }
.sf-dur-500 { --sf-sec-anim-dur: 500ms; } .sf-dur-550 { --sf-sec-anim-dur: 550ms; } .sf-dur-600 { --sf-sec-anim-dur: 600ms; } .sf-dur-650 { --sf-sec-anim-dur: 650ms; }
.sf-dur-700 { --sf-sec-anim-dur: 700ms; } .sf-dur-750 { --sf-sec-anim-dur: 750ms; } .sf-dur-800 { --sf-sec-anim-dur: 800ms; } .sf-dur-850 { --sf-sec-anim-dur: 850ms; }
.sf-dur-900 { --sf-sec-anim-dur: 900ms; } .sf-dur-950 { --sf-sec-anim-dur: 950ms; } .sf-dur-1000 { --sf-sec-anim-dur: 1000ms; } .sf-dur-1050 { --sf-sec-anim-dur: 1050ms; }
.sf-dur-1100 { --sf-sec-anim-dur: 1100ms; } .sf-dur-1150 { --sf-sec-anim-dur: 1150ms; } .sf-dur-1200 { --sf-sec-anim-dur: 1200ms; } .sf-dur-1250 { --sf-sec-anim-dur: 1250ms; }
.sf-dur-1300 { --sf-sec-anim-dur: 1300ms; } .sf-dur-1350 { --sf-sec-anim-dur: 1350ms; } .sf-dur-1400 { --sf-sec-anim-dur: 1400ms; } .sf-dur-1450 { --sf-sec-anim-dur: 1450ms; }
.sf-dur-1500 { --sf-sec-anim-dur: 1500ms; } .sf-dur-1550 { --sf-sec-anim-dur: 1550ms; } .sf-dur-1600 { --sf-sec-anim-dur: 1600ms; } .sf-dur-1650 { --sf-sec-anim-dur: 1650ms; }
.sf-dur-1700 { --sf-sec-anim-dur: 1700ms; } .sf-dur-1750 { --sf-sec-anim-dur: 1750ms; } .sf-dur-1800 { --sf-sec-anim-dur: 1800ms; } .sf-dur-1850 { --sf-sec-anim-dur: 1850ms; }
.sf-dur-1900 { --sf-sec-anim-dur: 1900ms; } .sf-dur-1950 { --sf-sec-anim-dur: 1950ms; } .sf-dur-2000 { --sf-sec-anim-dur: 2000ms; }

.sf-del-0 { --sf-sec-anim-delay: 0ms; } .sf-del-50 { --sf-sec-anim-delay: 50ms; } .sf-del-100 { --sf-sec-anim-delay: 100ms; } .sf-del-150 { --sf-sec-anim-delay: 150ms; }
.sf-del-200 { --sf-sec-anim-delay: 200ms; } .sf-del-250 { --sf-sec-anim-delay: 250ms; } .sf-del-300 { --sf-sec-anim-delay: 300ms; } .sf-del-350 { --sf-sec-anim-delay: 350ms; }
.sf-del-400 { --sf-sec-anim-delay: 400ms; } .sf-del-450 { --sf-sec-anim-delay: 450ms; } .sf-del-500 { --sf-sec-anim-delay: 500ms; } .sf-del-550 { --sf-sec-anim-delay: 550ms; }
.sf-del-600 { --sf-sec-anim-delay: 600ms; } .sf-del-650 { --sf-sec-anim-delay: 650ms; } .sf-del-700 { --sf-sec-anim-delay: 700ms; } .sf-del-750 { --sf-sec-anim-delay: 750ms; }
.sf-del-800 { --sf-sec-anim-delay: 800ms; } .sf-del-850 { --sf-sec-anim-delay: 850ms; } .sf-del-900 { --sf-sec-anim-delay: 900ms; } .sf-del-950 { --sf-sec-anim-delay: 950ms; }
.sf-del-1000 { --sf-sec-anim-delay: 1000ms; } .sf-del-1050 { --sf-sec-anim-delay: 1050ms; } .sf-del-1100 { --sf-sec-anim-delay: 1100ms; } .sf-del-1150 { --sf-sec-anim-delay: 1150ms; }
.sf-del-1200 { --sf-sec-anim-delay: 1200ms; }

/* ── REDUCED MOTION TURNS IT OFF. NOT DOWN. ──────────────────────────────────────────────────────
   No entrance, no hover transform, no transition, nothing left half-faded. Every selector that set
   an animation above is repeated here at the same specificity and after it in source order, which
   is what lets these win without `!important` — the pattern the carousel's stop already uses.
   `opacity: 1` is here because the waiting rule above is the one thing that could otherwise leave
   a block hidden for someone whose script did run before their preference was read.

   motion.js checks the same preference before it adds its class, so on these devices there is
   normally nothing to override. This block is what makes that belt-and-braces rather than trust.
   The `force_motion` param is declared in the schema and there is deliberately no rule here that
   reads it: a per-block switch that overrules an accessibility request is a switch that gets left
   on by someone who never reads it. */
@media (prefers-reduced-motion: reduce) {
  .sf-anim, .sf-mi-anim, .sf-anim .sf-mi-anim,
  html.sf-motion .sf-anim:not(.sf-in),
  html.sf-motion .sf-anim-host:not(.sf-in) .sf-mi-anim {
    animation: none; opacity: 1;
  }
  .sf-hover-lift, .sf-hover-sink, .sf-hover-glow-border,
  .sf-mi-hover-lift, .sf-mi-hover-sink, .sf-mi-hover-glow-border,
  .sf-hover-zoom-image img {
    transition: none;
  }
  .sf-hover-lift:hover, .sf-hover-sink:hover, .sf-hover-glow-border:hover,
  .sf-mi-hover-lift:hover, .sf-mi-hover-sink:hover, .sf-mi-hover-glow-border:hover,
  .sf-hover-zoom-image:hover img {
    transform: none; box-shadow: none;
  }
}

/* ── THE CHROME SCOPE WRAPPER ──────────────────────────────────────────────────────────────────
   One rule, and it is the whole cost in this file of giving a header or footer placement a CSS
   scope of its own (ChromeCss).

   A placement has no row id to be scoped by the way `.sf-sec-<id>` scopes a block, so the chrome
   templates draw a wrapper carrying `.sf-chr-<element>-<n>` — or `.sf-chr-<page>-<element>-<n>` on
   a page that has forked that element — and the generated stylesheet writes that placement's custom
   properties onto it. The wrapper exists ONLY to carry the class: the micros are the widget's
   siblings, so `.sf-chr-header-3 .sf-mi-1` needs something that contains both.

   `display: contents` is what makes that free. A header region is a flex row and a footer region is
   a stack; a wrapper that generated a box would become one flex item holding the widget and its
   chips together, changing the gaps on three trading shops. It generates none, so the children sit
   exactly where they sat — and custom properties inherit through it, which is how a value set on the
   placement reaches `.sf-cart-link` underneath and is read there with the widget's own value as the
   fallback.

   The wrapper is drawn only where a merchant has actually chosen something (StoreChromeService's
   `scope` is '' otherwise), so a shop that has styled nothing has no `.sf-chr-slot` on its page at
   all and this rule matches nothing. */
.sf-chr-slot { display: contents; }

/* ── DESIGN CONTROLS ON A CHROME WIDGET ────────────────────────────────────────────────────────
   Owner: "in cart icon case i need add customize design like curve adgest option, colour add
   option, padding etc — related all mini widget need this like options also".

   ChromeWidgets::DESIGN declares eleven controls and merges them onto EVERY widget kind. This is
   the other end of them, and it is the whole of what they cost the shop.

   ── THREE RULES SHAPE EVERY DECLARATION BELOW, AND ALL THREE ARE ABOUT SHOPS THAT ARE TRADING ──

   1. EVERY SELECTOR STARTS `.sf-chr-slot >`. That wrapper is drawn only where the merchant has
      actually chosen something (ChromeCss::needsScope), so on the three live shops — which have
      arranged their chrome and styled none of it — not one rule here matches an element. It is not
      "provably a no-op with nothing set" by careful fallbacks alone; it is a no-op because there is
      nothing to match. The fallbacks then keep the OTHER ten properties honest on a widget where
      the merchant set one.

   2. EVERY FALLBACK IS THE VALUE THAT WIDGET HAS TODAY, read off the rules above rather than
      invented — `.sf-hdr-btn` falls back to its own 7px 15px and 999px, `.sf-cart-link` to 0 and 0,
      the search field to its own 1px hairline. An unset property is therefore the widget unchanged,
      which is what makes "blank means the theme decides" true rather than aspirational.

   3. `--sf-chr-colour` IS ALWAYS READ OVER `--sf-chr-ink-auto`. Choosing a background emits an ink
      the platform knows is readable on it (ChromeWidgets::AUTO_INK); choosing a text colour beats
      it. So a merchant who sets only a background never ends up with words they cannot see, and one
      who sets both gets exactly what they asked for.

   CSP: `default-src 'self'`, no style-src. Every value here arrives as a custom property in the
   store's GENERATED stylesheet, inherited through the `display: contents` wrapper. No style
   attribute — the browser would drop it — and no JavaScript.

   The widgets are grouped by WHAT THEY HAVE TODAY, not by which element they live in: a header text
   line and a footer text line are 13.5px muted in both places, and splitting them would be two
   rules that have to be kept in step for no reason. */

/* THE BOX — every widget that has no box of its own today. `border: 0 …` computes to no border and
   `background: transparent` to no fill, so with nothing set these are the values they already had. */
.sf-chr-slot > .sf-brand,
.sf-chr-slot > .sf-nav,
.sf-chr-slot > .sf-cart-link,
.sf-chr-slot > .sf-footer-menu,
.sf-chr-slot > .sf-footer-nav,
.sf-chr-slot > .sf-ftr-note,
.sf-chr-slot > .sf-hdr-text,
.sf-chr-slot > .sf-ftr-text,
/* The promises row joins the SHARED rule rather than getting one of its own: a second copy of this
   property list is a second authority, and it would drift the first time one gained a control. */
.sf-chr-slot > .sf-hdr-promises,
.sf-chr-slot > .sf-social,
/* Payments (PRO CHROME 2026-08-13) joins the shared rule for the same reason promises did. */
.sf-chr-slot > .sf-pay,
/* Trustpilot (2026-09-02), same reason again. Padding, background, border and width all apply to
   the box their iframe lands in, so they are read here rather than copied into a rule of its own. */
.sf-chr-slot > .sf-tp,
.sf-chr-slot > .sf-footer-legal,
/* Newsletter signup (0163) — the same design params every other widget reads: a merchant styling
   their footer must be able to give the signup box a background and padding like anything else. */
.sf-chr-slot > .sf-ftr-nl,
.sf-chr-slot > .sf-footer-sub {
  background: var(--sf-chr-bg, transparent);
  padding: var(--sf-chr-pad-top, var(--sf-chr-pad-y, 0)) var(--sf-chr-pad-right, var(--sf-chr-pad-x, 0)) var(--sf-chr-pad-bottom, var(--sf-chr-pad-y, 0)) var(--sf-chr-pad-left, var(--sf-chr-pad-x, 0));
  border: var(--sf-chr-bd-w, 0) var(--sf-chr-bd-style, solid) var(--sf-chr-bd-colour, currentColor);
  border-radius: var(--sf-chr-bd-radius, 0);
  box-shadow: var(--sf-chr-shadow, none);
  /* SIZE. `auto` is what each of these does today, so with nothing set nothing changes. `max-width`
     rides along rather than being a control of its own: a fixed width wider than a phone is the one
     way this can break a header, and nobody choosing 220px is asking for a sideways-scrolling shop.
     A width needs a box to apply to, which is why these are on the widget's own root and not on
     `.sf-chr-slot` — that wrapper is `display: contents` and has no box. */
  width: var(--sf-chr-w, auto);
  height: var(--sf-chr-h, auto);
  max-width: 100%;
}

/* THE ACCOUNT CONTROL (2026-09-09). Scoped to the row rather than the link inside it: signed in the
   widget is TWO controls — the shopper's name and the way out — and a merchant who sets a background
   means the control, not half of it. Padding and radius start at 0 because the row is transparent
   today, so a shop that never opens the panel sees exactly what it saw before. */
.sf-chr-slot > .sf-acc {
  background: var(--sf-chr-bg, transparent);
  padding: var(--sf-chr-pad-top, var(--sf-chr-pad-y, 0)) var(--sf-chr-pad-right, var(--sf-chr-pad-x, 0)) var(--sf-chr-pad-bottom, var(--sf-chr-pad-y, 0)) var(--sf-chr-pad-left, var(--sf-chr-pad-x, 0));
  border: var(--sf-chr-bd-w, 0) var(--sf-chr-bd-style, solid) var(--sf-chr-bd-colour, currentColor);
  border-radius: var(--sf-chr-bd-radius, 0);
  box-shadow: var(--sf-chr-shadow, none);
  width: var(--sf-chr-w, auto);
  height: var(--sf-chr-h, auto);
  max-width: 100%;
}

/* THE TWO BUTTONS, which already are boxes. Their padding and radius are their own, so those are
   the fallbacks — a merchant who sets a colour on a button must not lose its pill. */
.sf-chr-slot > .sf-hdr-btn {
  background: var(--sf-chr-bg, var(--sf-brand));
  padding: var(--sf-chr-pad-top, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-right, var(--sf-chr-pad-x, 15px)) var(--sf-chr-pad-bottom, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-left, var(--sf-chr-pad-x, 15px));
  border: var(--sf-chr-bd-w, 0) var(--sf-chr-bd-style, solid) var(--sf-chr-bd-colour, currentColor);
  border-radius: var(--sf-chr-bd-radius, 999px);
  box-shadow: var(--sf-chr-shadow, none);
  /* SIZE, which this group was offered and did not read (F4). The widget schema has carried
     `width` and `height` on every kind since the box group landed; three kinds — both buttons and
     the search field — got their own rule block for padding and radius and never got these, so a
     merchant setting a width saw `--sf-chr-w` appear in theme.css and nothing move. Store 1 has
     had `480x200` on its search box the whole time.
     `max-width` rides along for the same reason it does on the box group: a fixed width wider than
     a phone is the one way this breaks a header. */
  width: var(--sf-chr-w, auto);
  height: var(--sf-chr-h, auto);
  max-width: 100%;
}
.sf-chr-slot > .sf-ftr-btn {
  background: var(--sf-chr-bg, transparent);
  padding: var(--sf-chr-pad-top, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-right, var(--sf-chr-pad-x, 15px)) var(--sf-chr-pad-bottom, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-left, var(--sf-chr-pad-x, 15px));
  border: var(--sf-chr-bd-w, 1px) var(--sf-chr-bd-style, solid) var(--sf-chr-bd-colour, var(--sf-border));
  border-radius: var(--sf-chr-bd-radius, 999px);
  box-shadow: var(--sf-chr-shadow, none);
  /* SIZE, which this group was offered and did not read (F4). The widget schema has carried
     `width` and `height` on every kind since the box group landed; three kinds — both buttons and
     the search field — got their own rule block for padding and radius and never got these, so a
     merchant setting a width saw `--sf-chr-w` appear in theme.css and nothing move. Store 1 has
     had `480x200` on its search box the whole time.
     `max-width` rides along for the same reason it does on the box group: a fixed width wider than
     a phone is the one way this breaks a header. */
  width: var(--sf-chr-w, auto);
  height: var(--sf-chr-h, auto);
  max-width: 100%;
}

/* ── THE SEARCH BOX IS STYLED THROUGH ITS FIELDS, NOT THROUGH ITS FORM ──────────────────────────
   The `<form>` is a transparent flex row; the thing a merchant points at and calls "the search box"
   is the rounded field inside it, and the category dropdown beside it has to match or the pair
   stops looking like one control. So the placement's properties are read one level in, on both.

   The submit button keeps the shop's ink-on-background inversion and is deliberately not restyled:
   it is the affordance that says "this is a search", and a merchant who softened the field's edge
   did not ask for the button to stop looking pressable. */
.sf-chr-slot > .sf-hdr-search > .sf-hdr-search-q {
  background: var(--sf-chr-bg, var(--sf-surface));
  padding: var(--sf-chr-pad-top, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-right, var(--sf-chr-pad-x, 13px)) var(--sf-chr-pad-bottom, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-left, var(--sf-chr-pad-x, 13px));
  border: var(--sf-chr-bd-w, 1px) var(--sf-chr-bd-style, solid) var(--sf-chr-bd-colour, var(--sf-border));
  border-radius: var(--sf-chr-bd-radius, 999px);
  box-shadow: var(--sf-chr-shadow, none);
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, var(--sf-ink)));
  font-size: var(--sf-chr-text-size, 13.5px);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, inherit);
  /* SIZE, which this group was offered and did not read (F4). The widget schema has carried
     `width` and `height` on every kind since the box group landed; three kinds — both buttons and
     the search field — got their own rule block for padding and radius and never got these, so a
     merchant setting a width saw `--sf-chr-w` appear in theme.css and nothing move. Store 1 has
     had `480x200` on its search box the whole time.
     `max-width` rides along for the same reason it does on the box group: a fixed width wider than
     a phone is the one way this breaks a header.

     ⚠️ THE FALLBACK IS `100%`, NOT `auto`, AND THE DIFFERENCE IS A REGRESSION THIS RULE CAUSED.
     The base rule two hundred lines up has always said `.sf-hdr-search input { width: 100% }` — the
     field fills the room its form was given. This block is more specific, so the day it started
     reading `--sf-chr-w` it also started answering `auto` for every shop that had set nothing, and
     those fields quietly shrank to their content inside a slot sized by `search_width`. Store 1
     showed it: a 198px box floating in a 640px slot with the magnifier stranded at the far end.
     `100%` restores what the base rule always meant; a merchant width still wins, because it is the
     value and this is only the fallback. */
  width: var(--sf-chr-w, 100%);
  height: var(--sf-chr-h, auto);
  max-width: 100%;
}
.sf-chr-slot > .sf-hdr-search > .sf-hdr-search-cat {
  background: var(--sf-chr-bg, var(--sf-surface));
  padding: var(--sf-chr-pad-top, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-right, var(--sf-chr-pad-x, 9px)) var(--sf-chr-pad-bottom, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-left, var(--sf-chr-pad-x, 9px));
  border: var(--sf-chr-bd-w, 1px) var(--sf-chr-bd-style, solid) var(--sf-chr-bd-colour, var(--sf-border));
  border-radius: var(--sf-chr-bd-radius, 999px);
  box-shadow: var(--sf-chr-shadow, none);
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, var(--sf-ink)));
  font-size: var(--sf-chr-text-size, 13px);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, inherit);
}

/* THE WORDS. Grouped by the colour and size each root has today — see rule 2 above. */
.sf-chr-slot > .sf-cart-link,
.sf-chr-slot > .sf-footer-menu,
.sf-chr-slot > .sf-footer-nav,
.sf-chr-slot > .sf-pay,
/* Newsletter signup (0163). The words INSIDE it inherit — heading, blurb and the consent sentence
   are all descendants — so one root reads the typography for the whole widget, which is how every
   other composite here works. */
.sf-chr-slot > .sf-ftr-nl,
.sf-chr-slot > .sf-ftr-note {
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, inherit));
  font-size: var(--sf-chr-text-size, inherit);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, inherit);
}
/* THE LOGO gets the colour and NOT the two type controls, because it already owns them: `text_size`
   and `text_weight` are the logo's own four named steps (ChromeWidgets::LOGO), and
   ChromeSettings::widgetFieldsFor keeps a kind's own param rather than letting the shared one shadow
   it. Two controls both called "size" is the second answer this platform refuses everywhere else. */
.sf-chr-slot > .sf-brand {
  font-family: var(--sf-chr-font, inherit);
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, inherit));
}
.sf-chr-slot > .sf-nav {
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, inherit));
  font-size: var(--sf-chr-text-size, 15px);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, inherit);
}
/* ⚠️ THE MENU'S OWN TWO CLASSES ARE RESTATED, and this is not tidiness. `.sf-nav.is-caps` sets a
   font-size and `.sf-nav.is-bold` a weight; both are one class plus one class, exactly as
   `.sf-chr-slot > .sf-nav` is, so source order alone would decide — and this file is below them.
   Without these two rules a merchant who set ANY design value on their menu would silently lose the
   uppercase size or the bold they chose under Menus. The pair is preserved instead: the merchant's
   slider still wins, and with the slider blank the menu keeps exactly the class it had. */
.sf-chr-slot > .sf-nav.is-caps { font-size: var(--sf-chr-text-size, 13px); }
/* Slice 9: the nav's link gap, scoped — the bare .sf-nav-l1 keeps its 22px literal for every menu
   outside a chrome placement, and the family rides the shared font var like every other word. */
.sf-chr-slot > .sf-nav .sf-nav-l1 { gap: var(--sf-chr-nav-gap, 22px); }
.sf-chr-slot > .sf-nav { font-family: var(--sf-chr-font, inherit); }
.sf-chr-slot > .sf-nav.is-bold { font-weight: var(--sf-chr-text-weight, 700); }
.sf-chr-slot > .sf-hdr-text,
.sf-chr-slot > .sf-hdr-promises,
.sf-chr-slot > .sf-social,
/* ⚠️ Trustpilot joins this too, and its typography reaches an IFRAME — which inherits none of it.
   The honest control for the words inside the box is the widget's own Text colour, which becomes
   `data-text-color` for their script to read. These are here so the shared design tab is not
   offering a merchant controls with no rule behind them at all, which is the defect this file's
   own tests hunt; the same is already true of the social marks' text-size. */
.sf-chr-slot > .sf-tp,
.sf-chr-slot > .sf-ftr-text {
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-muted)))));
  font-size: var(--sf-chr-text-size, 13.5px);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, inherit);
}
.sf-chr-slot > .sf-footer-legal {
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-faint)))));
  font-size: var(--sf-chr-text-size, 12px);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, inherit);
}
.sf-chr-slot > .sf-footer-sub {
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, var(--sf-band-ink, var(--sf-band-ink-auto, var(--sf-faint)))));
  font-size: var(--sf-chr-text-size, inherit);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, inherit);
}
/* The account control's words. `color` lands on the ROW, so the name, the second door and the
   stroked glyph inside it all inherit it — the glyph is drawn in `currentColor` exactly so one
   Text colour reaches every part of the control rather than needing a rule of its own. */
.sf-chr-slot > .sf-acc {
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, inherit));
  font-size: var(--sf-chr-text-size, inherit);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, inherit);
}
.sf-chr-slot > .sf-hdr-btn {
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, #fff));
  font-size: var(--sf-chr-text-size, 13px);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, 700);
}
.sf-chr-slot > .sf-ftr-btn {
  color: var(--sf-chr-colour, var(--sf-chr-ink-auto, inherit));
  font-size: var(--sf-chr-text-size, 13px);
  font-family: var(--sf-chr-font, inherit);
  font-weight: var(--sf-chr-text-weight, 600);
}

/* ⚠️ THE FOOTER'S MARGIN RESET HAS TO REACH THROUGH THE WRAPPER, AND IT IS A REPAIR.
   `.sf-ftr-top > *, .sf-ftr-bar > * { margin: 0 }` is what stops a <p> in the footer wearing the
   browser's default 1em margins. `display: contents` does not change what a selector matches — the
   wrapper is still the child — so the moment a placement gained a scope its paragraph got its
   default margins back and the footer grew gaps nobody asked for. This is the same instruction, one
   level deeper, and it is (0,3,0) so it wins over `.sf-footer-legal`'s own 4px wherever the reset
   already did. The micro band is a child of the wrapper too and was also being zeroed before, so it
   is covered by the same `*`. */
.sf-ftr-top > .sf-chr-slot > *, .sf-ftr-bar > .sf-chr-slot > * { margin: 0; }

/* ══ THE BASKET, MADE INTO SOMETHING (owner, 2026-08-04) ═══════════════════════════════════════
 *
 * Owner, verbatim: *"this cart icon we dont have make to more atractive its limited"*. It was a
 * glyph from a list of six, a word and a number — and the two controls that decide whether it reads
 * as a shop's basket or as a stray link (how big the glyph is, and whether the whole thing is a
 * button) did not exist.
 *
 * ── ⚠️ WHY THESE ARE NOT SCOPED UNDER `.sf-chr-slot`, WHEN ALMOST EVERY CHROME RULE IS ──────────
 *
 * The slot wrapper is drawn only when a placement puts something in the STYLESHEET
 * (ChromeCss::needsScope). `variant`, `icon_at` and `dot` are server-chosen CLASSES — they emit no
 * custom property, so a basket wearing only a variant has NO wrapper. A rule written
 * `.sf-chr-slot > .sf-cart-link.sf-cart-pill` would match nothing and the merchant would pick "a
 * filled pill" and watch the link stay a link. That is the defect this module keeps finding, and it
 * is avoided here by not needing the wrapper at all.
 *
 * ⚠️ SO SPECIFICITY IS SETTLED BY SOURCE ORDER, DELIBERATELY, AND THIS BLOCK MUST STAY LAST.
 * `.sf-chr-slot > .sf-cart-link` (0,2,0) and `.sf-cart-link.sf-cart-pill` (0,2,0) are equal, so the
 * later one wins. That is the behaviour wanted: both read the SAME custom properties, so a value the
 * merchant actually set still wins through the `var()`, and only the FALLBACK differs — a pill falls
 * back to brand and a pill's padding, a plain link falls back to nothing. Moving this block above the
 * chrome rules would silently return every pill to a bare link.
 */

/* ── The glyph's size — defect #51, for the one kind that draws a glyph ────────────────────────
 *
 * ⚠️ SCOPED BY SELECTOR TO THE CART'S OWN GLYPH, never written as a bare `.sf-mi-glyph`. A
 * placement's micro band renders inside the same wrapper, so a global rule would silently resize the
 * trust chip standing next to the basket.
 *
 * ⚠️ AND THE FALLBACK IS 15px, NOT the text size. Matching the words is what a merchant usually
 * wants and is NOT a safe default: store 1 is trading with `text_size: 28` and a 15px glyph, so a
 * fallback of `var(--sf-chr-text-size)` would resize a live shop's basket on deploy. A release does
 * not get to redesign a shop — see the note beside `icon_size` in ChromeWidgets. */
.sf-cart-link > .sf-mi-glyph {
  width: var(--sf-chr-icon, 15px);
  height: var(--sf-chr-icon, 15px);
}

/* ── A TOUCH TARGET. An icon-only basket was a 15x15 box, which fails WCAG 2.5.8 on both axes.
   Only where there IS a glyph, so the classic header's worded link is untouched. */
.sf-cart-link:has(> .sf-mi-glyph) { min-height: 24px; min-width: 24px; justify-content: center; }

/* ── ⚠️ THE HOVER, PUT BACK. A LIVE BUG, not a new control ─────────────────────────────────────
 *
 * `.sf-nav a:hover { color: var(--sf-brand); }` reaches the basket only while it is a DESCENDANT of
 * the nav — which it is on a classic header and is not the moment the header is arranged, because
 * the basket then becomes its own widget in its own region. Confirmed in the served markup:
 * alectrofag.co.uk has it inside `<nav class="sf-nav">`, vapeandgo and vapebig do not.
 *
 * So two trading shops lost the basket's hover by rearranging their header, and nothing said so.
 * This is NOT the "hover colour" control that {@see ChromeWidgets::DESIGN} deliberately refuses —
 * that refusal is about not giving the merchant a SECOND answer. This restores the shop's ONE
 * answer to the one element that had it taken away. */
.sf-cart-link:hover { color: var(--sf-brand); }

/* ── The icon after the words ─────────────────────────────────────────────────────────────────
   `order` and not `row-reverse`: the words are a bare text node, so they are an ANONYMOUS flex item
   with order 0 and cannot be reordered themselves. Reversing the row would also throw the count to
   the far side. Giving the glyph 1 and the count 2 puts them in the one sequence a merchant means by
   "after the words". */
.sf-cart-ia > .sf-mi-glyph { order: 1; }
.sf-cart-ia > .sf-cart-n { order: 2; }

/* ── The two shapes ───────────────────────────────────────────────────────────────────────────
   Both read the widget's own Background, Text colour, padding and radius, so a merchant who sets
   those keeps them; only the FALLBACKS differ. That is what makes this a starting point rather than
   a fourth authority over the same four properties. */
.sf-cart-link.sf-cart-pill {
  background: var(--sf-chr-bg, var(--sf-brand));
  color: var(--sf-chr-colour, var(--sf-brand-ink));
  padding: var(--sf-chr-pad-top, var(--sf-chr-pad-y, 8px)) var(--sf-chr-pad-right, var(--sf-chr-pad-x, 16px)) var(--sf-chr-pad-bottom, var(--sf-chr-pad-y, 8px)) var(--sf-chr-pad-left, var(--sf-chr-pad-x, 16px));
  border-radius: var(--sf-chr-bd-radius, 999px);
}
.sf-cart-link.sf-cart-outline {
  background: var(--sf-chr-bg, transparent);
  color: var(--sf-chr-colour, var(--sf-brand));
  padding: var(--sf-chr-pad-top, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-right, var(--sf-chr-pad-x, 15px)) var(--sf-chr-pad-bottom, var(--sf-chr-pad-y, 7px)) var(--sf-chr-pad-left, var(--sf-chr-pad-x, 15px));
  border: var(--sf-chr-bd-w, 1px) var(--sf-chr-bd-style, solid) var(--sf-chr-bd-colour, currentColor);
  border-radius: var(--sf-chr-bd-radius, 999px);
}
/* A pill's own hover, because the rule above sets a colour the pill has just overridden and a filled
   button whose text goes brand-on-brand is unreadable for the length of the hover. */
.sf-cart-link.sf-cart-pill:hover { color: var(--sf-chr-colour, var(--sf-brand-ink)); }

/* ── The count's own two colours, and the dot ─────────────────────────────────────────────────
   The properties are NEW names read by nothing else — the trap the reverted header-band work hit was
   reusing `--sf-chr-bg`, which every widget already reads. Unset on every shop that has not chosen,
   so the bubble stays exactly the brand pill it is today. */
.sf-cart-n {
  background: var(--sf-chr-badge-bg, var(--sf-brand));
  color: var(--sf-chr-badge-ink, var(--sf-brand-ink));
}
/* A dot says "there is something in here" without the shopper having to read it. It renders EMPTY —
   the count is not hidden from a screen reader, it is in the link's own aria-label. */
.sf-cart-n-dot { min-width: 9px; width: 9px; height: 9px; padding: 0; font-size: 0; }

/* ── THE COUNT ON THE ICON'S CORNER (`count_at: on_icon`) ─────────────────────────────────────
   The shape most carts use, and the reason is width: an inline badge makes the button grow when
   the number reaches two digits, so the header reflows while a shopper is adding things. On the
   corner it cannot.

   The glyph is wrapped so the badge has something to be positioned against — `position: relative`
   goes on the WRAPPER and not on the link, because the link is what the pill's own padding and
   border-radius are measured from and giving it a positioning context moves nothing but invites
   the next person to hang something off it. */
/* ⚠️ THE ANCHOR IS THE GLYPH'S OWN BOX, not the link's. Positioning against the link put the badge
   wherever the button's padding happened to leave it — floating over the middle rather than on the
   icon — and it moved every time the padding or the label changed. `.sf-cart-ig` wraps the glyph and
   the badge together, so "the icon's top-right corner" is a thing that can actually be said. */
.sf-cart-ig { position: relative; display: inline-flex; align-items: center; flex: 0 0 auto; }
.sf-cart-cn-on > .sf-cart-ig > .sf-cart-n {
  position: absolute; top: -5px; right: -11px;
  /* 20 × 20 at 12px — measured off the reference cart, and a round badge rather than a pill:
     `min-width` equal to `height` keeps a single digit circular, and padding lets a three-digit
     basket stretch it sideways instead of clipping. */
  min-width: 20px; height: 20px; padding: 0 5px; font-size: 12px; font-weight: 500;
  /* Sits over the icon, so it needs its own edge against whatever the pill is filled with. */
  box-shadow: 0 0 0 2px var(--sf-chr-bg, var(--sf-brand));
}
/* A merchant's own glyph, when it is a picture rather than a drawing. Sized by the same
   `--sf-chr-icon` the SVG uses, so the two are interchangeable at any size. */
.sf-cart-img { width: var(--sf-chr-icon, 15px); height: var(--sf-chr-icon, 15px); object-fit: contain; display: block; flex: 0 0 auto; }

/* ── The basket total (chrome `basket` widget, `subtotal`) ────────────────────────────────────
   Measured on vapeuk, vapeclub and vape.co.uk: the total sits beside the count in the SAME line
   colour, one weight lighter than the words. It inherits `color` from the link rather than naming
   one, so the widget's own Text colour — and the pill's inverted ink — reach it without a second
   rule to keep in step. `tabular-nums` so the header does not reflow by a pixel each time the total
   changes width. */
.sf-cart-sum { font-weight: 600; font-variant-numeric: tabular-nums; }
/* After the count, and after the glyph when the merchant has moved that too — the anonymous text
   node keeps order 0, so anything meant to sit at the end has to say so. */
.sf-cart-ia > .sf-cart-sum { order: 3; }

/* ── The mini basket (chrome `basket` widget, `open: drawer`) ─────────────────────────────────
   A panel over the page, never a replacement for it: the shop is still underneath and closing puts
   the shopper back exactly where they were. Everything here is inert until minicart.js appends the
   element, so a shop without the drawer ships these rules and draws nothing — the same deal every
   other optional widget's CSS gets. */
.sf-mc { position: fixed; inset: 0; z-index: 60; }
.sf-mc[hidden] { display: none; }
.sf-mc-scrim { position: absolute; inset: 0; background: rgba(15, 23, 41, 0.45); }
.sf-mc-box {
  position: absolute; top: 0; right: 0; bottom: 0; width: min(400px, 92vw);
  display: flex; flex-direction: column;
  background: var(--sf-bg, #fff); color: var(--sf-ink, #0f1729);
  box-shadow: -8px 0 32px rgba(15, 23, 41, 0.18);
}
/* The page behind must not scroll under an open panel — a shopper who scrolls the list and finds
   the shop moving instead has lost their place in both. */
.sf-mc-on, .sf-mc-on body { overflow: hidden; }

.sf-mc-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--sf-line, #e6e8ef); }
.sf-mc-title { margin: 0; font-size: 16px; font-weight: 700; }
.sf-mc-x { border: 0; background: none; color: inherit; font-size: 24px; line-height: 1; cursor: pointer; padding: 0 4px; }

.sf-mc-list { flex: 1 1 auto; overflow-y: auto; padding: 8px 18px; }
.sf-mc-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--sf-line, #e6e8ef); }
.sf-mc-img { width: 48px; height: 48px; object-fit: cover; border-radius: 8px; flex: 0 0 auto; }
.sf-mc-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.sf-mc-name { font-size: 14px; font-weight: 600; }
.sf-mc-qty { font-size: 13px; opacity: 0.7; }
.sf-mc-line { font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
.sf-mc-empty { padding: 24px 0; text-align: center; opacity: 0.75; }
/* The basket changed under the shopper's feet, and this is where they find out — see #16. */
.sf-mc-notice { margin: 12px 0 0; padding: 10px 12px; border-radius: 8px; background: #fff7ed; color: #7c2d12; font-size: 13px; }

.sf-mc-foot { padding: 16px 18px; border-top: 1px solid var(--sf-line, #e6e8ef); display: flex; flex-direction: column; gap: 12px; }
.sf-mc-free-t { margin: 0 0 6px; font-size: 13px; }
.sf-mc-bar { height: 6px; border-radius: 999px; background: var(--sf-line, #e6e8ef); overflow: hidden; }
.sf-mc-fill { display: block; height: 100%; background: var(--sf-brand, #0e9f6e); }
.sf-mc-total { display: flex; align-items: baseline; justify-content: space-between; font-weight: 700; }
.sf-mc-total-l { font-size: 14px; }
.sf-mc-total-v { font-variant-numeric: tabular-nums; }
.sf-mc-acts { display: flex; gap: 10px; }
.sf-mc-b {
  flex: 1 1 0; text-align: center; padding: 12px 14px; border-radius: 10px;
  background: var(--sf-brand, #0e9f6e); color: var(--sf-brand-ink, #fff);
  font-weight: 600; text-decoration: none;
}
.sf-mc-b-alt { background: transparent; color: inherit; border: 1px solid var(--sf-line, #e6e8ef); }

/* ── The promises row (chrome `promises` widget) ─────────────────────────────
   The most frequent pattern in the measured reference set — all six readable
   shops carry a strip of short reassurances. Inline content in a slot, so it
   inherits the band's ink by the ordinary cascade like every other widget, and
   a merchant who has coloured the band gets legible promises for free.

   `flex-wrap` matters: four promises at 390px is two rows, and the alternative
   is a row that overflows its own header. */
.sf-hdr-promises { display: flex; align-items: center; flex-wrap: nowrap; gap: 6px 16px; min-width: 0;
  /* ⚠️ NOWRAP, AND THE MEASUREMENT SAYS SO. The shop this is matched against runs its strip as a
     DRAG carousel: `overflow: hidden`, a track wider than its box, and when the room runs out the
     promises slide out of it — they never fall onto a second line. `flex-wrap: wrap` here was the
     whole bug: a fourth promise dropped under the first three and the 64px band grew to hold it.

     Scrolling rather than a script, because that IS the drag: a touch device pans it, a trackpad
     swipes it, and the keyboard can reach it — none of which a transform-driven track gives without
     JavaScript the storefront CSP would have to be widened for. */
  overflow-x: auto; overflow-y: hidden; scrollbar-width: none; scroll-snap-type: x proximity;
  overscroll-behavior-x: contain; }
.sf-hdr-promises::-webkit-scrollbar { display: none; }
/* The items never SHRINK; a shrunk promise wraps its own text, which is the second line by another
   route. Snapping to the start of each means a pan always lands on a whole promise.

   ⚠️ THEY DO GROW, THOUGH — `1 0 auto`, not `0 0 auto` (owner ask, 2026-09-10). The reference's
   four promises fill their box because Owl gives each a fixed 314px and 4 × 314 = 1256 exactly (see
   the note by `.sf-pr-marq`, and do read it before touching this row). Ours carry shorter wording,
   so at natural width they clustered at the left of a much wider band with dead space after them.

   Growth is the right mechanism here and `justify-content` is not: free space only exists when the
   row already fits, so `flex-grow` fills the band in exactly that case and is INERT the moment the
   promises overflow — which is when the nowrap-and-pan behaviour above takes over, untouched. A
   `space-between` would have fought it, and "spread apart" is refused by name in the alignment
   vocabulary for that reason. `flex-shrink` stays 0, so nothing here can squash text.

   Not `1 1 0`: equal-width columns would divide the band evenly and then squash rather than pan,
   which is the reference's LOOK arrived at by breaking the reference's BEHAVIOUR. */
.sf-hdr-promises > .sf-pr { flex: 1 0 auto; scroll-snap-align: start; }

/* ── Social links (chrome `social` widget) ────────────────────────────────────────────────────
   Brand marks in currentColor, so the widget's Text colour and the band's ink reach them by the
   ordinary cascade. The glyph rides --sf-chr-icon exactly as the basket's does — one size
   vocabulary for chrome glyphs, not two. */
.sf-social { display: inline-flex; align-items: center; gap: 12px; }
.sf-social-a { display: inline-flex; color: inherit; }
.sf-social-a:hover { color: var(--sf-brand); }
.sf-social-a svg { width: var(--sf-chr-icon, 18px); height: var(--sf-chr-icon, 18px); }
.sf-pr { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.3; }
/* The icon is the storefront's own closed-list SVG macro (`_micros.html.twig`),
   the same six glyphs the trust-chip micro draws — markup, not style, so the CSP
   has no opinion on it and there is one icon vocabulary rather than two. It
   inherits `currentColor`, so a band's ink colours the glyph with the words. */
.sf-pr .sf-mi-glyph { width: 14px; height: 14px; flex: none; }

/* ── The announcement rotator (gap #5) ───────────────────────────────────────
   All messages are in the markup; only opacity changes. A screen reader reads
   each as ordinary text in order — where a script swapping textContent would
   announce a moving target. And it needs no script at all, which the storefront
   CSP (no script-src) would have made awkward anyway.

   The messages stack in one grid cell so the strip's height is the tallest of
   them and never jumps as they cycle. */
.sf-an-rot { display: grid; }
.sf-an-rot > .sf-an-m { grid-area: 1 / 1; opacity: 0; animation: sf-an-cycle var(--sf-an-dur, 15s) infinite; }
.sf-an-s-slow   { --sf-an-step: 8s; }
.sf-an-s-normal { --sf-an-step: 5s; }
.sf-an-s-brisk  { --sf-an-step: 3s; }
/* Total = step x count, and each message is delayed by one step. */
.sf-an-n2 > .sf-an-m { --sf-an-dur: calc(var(--sf-an-step, 5s) * 2); }
.sf-an-n3 > .sf-an-m { --sf-an-dur: calc(var(--sf-an-step, 5s) * 3); }
.sf-an-n2 > .sf-an-m:nth-child(2) { animation-delay: var(--sf-an-step, 5s); }
.sf-an-n3 > .sf-an-m:nth-child(2) { animation-delay: var(--sf-an-step, 5s); }
.sf-an-n3 > .sf-an-m:nth-child(3) { animation-delay: calc(var(--sf-an-step, 5s) * 2); }

/* ── THE PROMISES ROW AS A SLIDING TRACK (an OPTION, not the reference behaviour) ─────────────
   ⚠️ TWO WRONG READINGS OF THE REFERENCE SHOP CAME BEFORE THIS ONE, AND BOTH ARE WORTH KEEPING.
   The first faded one promise at a time. The second — this marquee — read `overflow: hidden` over
   an over-wide track and concluded "continuous crawl". Sampling the live transform for twenty
   seconds settled it: `owl-carousel owl-loaded owl-drag`, `animationName: none`, and a transform
   pinned at -2198px that NEVER MOVED. Four 314px items in a 1256px box: it fits exactly, sits
   still, and only travels when a person drags it.

   So the shop's real behaviour is the PLAIN row above — nowrap, clipped, pannable. This marquee is
   a deliberate extra a merchant can choose, and the CSS below is correct for what it claims to be;
   it is simply not what the reference does. Reading a static carousel's markup and inferring motion
   from it is the mistake this paragraph exists to stop somebody making a third time.

   Reuses `sf-carousel-run`, the product carousel's own marquee keyframe, rather than growing a
   second one. It translates by exactly -50%, which is correct only because the track holds the set
   TWICE — so the loop repeats on the seam and never jumps. The duplicate set is `aria-hidden` in
   the markup: the same sentences again, and a screen reader must not read them twice.

   ⚠️ `flex: 1 1 0` WITH `min-width: 0`, never `width: 100%`. The row is a flex line that also holds
   the Trustpilot box, and a full-width child pushed it onto a second line inside a 64px strip. A
   marquee still needs a bounded box to clip against, and what is left after its neighbours is
   exactly the right bound — it is the space the strip actually has. */
.sf-hdr-promises.sf-pr-marq { display: block; overflow: hidden; flex: 1 1 0; min-width: 0; }
.sf-pr-track { display: flex; width: max-content; animation: sf-carousel-run var(--sf-pr-secs, 40s) linear infinite; }
.sf-pr-set { display: flex; align-items: center; gap: 22px; padding-right: 22px; flex: 0 0 auto; }
/* Distances per lap rather than dwell times: a marquee has no "each", so a longer strip at the same
   setting simply takes longer to come round. */
.sf-pr-s-slow   { --sf-pr-secs: 60s; }
.sf-pr-s-normal { --sf-pr-secs: 40s; }
.sf-pr-s-brisk  { --sf-pr-secs: 25s; }
/* Stopping to read one is the only thing that makes a strip like this readable. */
.sf-hdr-promises.sf-pr-marq:hover .sf-pr-track { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  /* No travel, and the duplicate hidden rather than left parked to one side. */
  .sf-pr-track { animation: none; }
  .sf-pr-track > .sf-pr-set[aria-hidden] { display: none; }
}
@keyframes sf-an-cycle {
  0%, 4%   { opacity: 0; }
  8%, 40%  { opacity: 1; }
  46%, 100% { opacity: 0; }
}
/* Four messages need their own narrower window: the shared keyframe holds each message visible for
   32% of the TOTAL, and 32% of four steps is 1.28 steps — two messages superimposed for a quarter
   of a step. 6–22% of four steps is 0.64 steps visible, gaps intact. */
.sf-an-n4 > .sf-an-m { --sf-an-dur: calc(var(--sf-an-step, 5s) * 4); animation-name: sf-an-cycle-4; }
.sf-an-n4 > .sf-an-m:nth-child(2) { animation-delay: var(--sf-an-step, 5s); }
.sf-an-n4 > .sf-an-m:nth-child(3) { animation-delay: calc(var(--sf-an-step, 5s) * 2); }
.sf-an-n4 > .sf-an-m:nth-child(4) { animation-delay: calc(var(--sf-an-step, 5s) * 3); }
@keyframes sf-an-cycle-4 {
  0%, 3%   { opacity: 0; }
  6%, 22%  { opacity: 1; }
  28%, 100% { opacity: 0; }
}
/* The strip's WORDS edge to edge (header plan slice 6) — the colour always was. */
.sf-announce.sf-ann-w-full .sf-wrap { max-width: none; }
/* ⚠️ REDUCED MOTION SHOWS THE FIRST MESSAGE ONLY. `animation: none` alone would
   leave all three at their static opacity: 0 — an empty strip — or, if that were
   flipped, all three stacked on top of each other. Neither is a message. */
@media (prefers-reduced-motion: reduce) {
  .sf-an-rot > .sf-an-m { animation: none; opacity: 0; }
  .sf-an-rot > .sf-an-m:first-child { opacity: 1; }
}

/* ── PAYMENT METHOD CHIPS (PRO CHROME, 2026-08-13) ─────────────────────────────────────────────
   Text chips, deliberately: CSP rules out hosted brand assets, and trademark artwork is a
   licensing question. Quiet, uniform, professional — the row says "we accept these" without
   shouting any one brand. */
/* ── ACCEPTED PAYMENT METHODS ──────────────────────────────────────────────────────────────────
   ⚠️ STILL TEXT, NOT BRAND ARTWORK, AND THAT DECISION STANDS. The widget's own note gives the
   reason — trademark artwork is a licensing question, not a template's call — and the reference's
   single `payments.svg` is a licensed asset belonging to that shop. Nothing here copies it.

   What DID change (2026-09-10): the reference presents these as a row of CARD MARKS, and ours read
   as faint tags — 11px, muted grey, pill-shaped, easy to mistake for keywords. Same words, wrong
   register. So the chip is now card-PROPORTIONED (a payment card is ~1.6:1, and a uniform minimum
   width stops "Visa" and "Mastercard" reading as different kinds of thing) and carries the band's
   own ink at full contrast rather than the muted tone used for supporting text.

   Presentation of a name we already display. No third-party file is fetched, embedded or redrawn. */
.sf-pay { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; }
.sf-pay-chip {
  display: inline-flex; align-items: center; justify-content: center;
  /* A card's proportions, so a row of these reads as payment marks rather than as tags. */
  min-width: 46px; height: 29px; padding: 0 9px;
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .01em;
  white-space: nowrap;
  /* ⚠️ `currentColor` at reduced alpha, NOT `--sf-muted`: this strip sits in the footer band, whose
     ink a merchant chooses. A fixed muted grey went invisible on a dark footer — which is exactly
     where this shop puts it. */
  color: inherit;
  background: color-mix(in srgb, currentColor 8%, transparent);
  border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
  border-radius: 4px;
}

/* ── BACK TO TOP (PRO CHROME). A plain fragment link; smooth only for those who allow motion. ── */
.sf-top {
  position: fixed;
  right: 16px;
  bottom: 16px;
  z-index: 40;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  font-size: 16px;
  text-decoration: none;
  color: var(--sf-ink);
  background: var(--sf-surface);
  border: 1px solid var(--sf-border);
  border-radius: 999px;
  box-shadow: 0 4px 14px rgba(15, 23, 41, .12);
}
.sf-top:hover { border-color: var(--sf-brand); color: var(--sf-brand); }
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }

/* ── Listing pager (2026-09-07) ────────────────────────────────────────────────────────────────
   Plain links, styled. Nothing here depends on JavaScript: the pager works, and is crawlable, with
   scripting off entirely — which is what keeps pages 2..n in an index. */
.sf-pager { display: flex; align-items: center; justify-content: center; gap: 8px;
  flex-wrap: wrap; margin: 28px 0 8px; }
.sf-pager-nums { display: flex; align-items: center; gap: 4px; list-style: none; margin: 0; padding: 0; }
.sf-pager-num, .sf-pager-step { display: inline-flex; align-items: center; justify-content: center;
  min-width: 38px; height: 38px; padding: 0 10px; border: 1px solid var(--sf-border, #e3e6ec);
  border-radius: var(--sf-radius, 8px); text-decoration: none; color: inherit; font-size: 14px; }
.sf-pager-num:hover, .sf-pager-step:hover { border-color: var(--sf-ink, #1f2430); }
.sf-pager-num.is-here { background: var(--sf-ink, #1f2430); color: #fff; border-color: var(--sf-ink, #1f2430);
  font-weight: 700; }
/* Inert prev/next: present so the pager does not shift sideways between pages. */
.sf-pager-step.is-off { opacity: .35; }
.sf-pager-gap { padding: 0 4px; opacity: .6; }
.sf-pager-count { text-align: center; font-size: 13px; opacity: .75; margin: 0 0 24px; }
@media (max-width: 520px) {
  /* On a phone the numbers are the first thing to go — prev/next still reach every page. */
  .sf-pager-nums .sf-pager-num:not(.is-here) { display: none; }
}

/* ── Latest from the blog (2026-09-10) ─────────────────────────────────────────────────────────
   The reference closes its home page with this band. Three across on desktop, one per row on a
   phone; the whole card is the link, so the target is the size of the card rather than the title. */
.sf-blogband-h { margin: 0 0 18px; font-size: 22px; font-weight: var(--sf-heading-weight, 700);
  letter-spacing: var(--sf-heading-tracking, normal); }
.sf-blogband-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px;
  list-style: none; margin: 0; padding: 0; }
.sf-blogband-link { display: block; height: 100%; padding: 18px; text-decoration: none; color: inherit;
  background: var(--sf-surface); border: 1px solid var(--sf-border); border-radius: var(--sf-radius); }
.sf-blogband-link:hover { border-color: var(--sf-ink); }
.sf-blogband-link:focus-visible { outline: 2px solid var(--sf-brand); outline-offset: 2px; }
.sf-blogband-t { margin: 0 0 8px; font-size: 16px; font-weight: 700; }
.sf-blogband-x { margin: 0 0 10px; font-size: 14px; line-height: 22px; color: var(--sf-muted); }
.sf-blogband-meta { font-size: 12.5px; color: var(--sf-muted); }
@media (max-width: 900px) { .sf-blogband-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .sf-blogband-list { grid-template-columns: minmax(0, 1fr); } }

/* ── Footer newsletter signup (0163) ───────────────────────────────────────────────────────────
   An addition beyond the reference, which carries no signup form at all — the owner asked for one.
   Sized to sit as the column row's last column rather than as a band across the footer. */
.sf-ftr-nl-h { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.sf-ftr-nl-b { font-size: 14px; line-height: 22px; margin: 0 0 12px; opacity: .85; }
.sf-ftr-nl-form { display: flex; flex-wrap: wrap; gap: 8px; }
.sf-ftr-nl-in { flex: 1 1 160px; min-width: 0; padding: 9px 11px; font: inherit; font-size: 14px;
  border: 1px solid var(--sf-border); border-radius: var(--sf-radius, 8px);
  background: var(--sf-surface); color: var(--sf-ink); }
.sf-ftr-nl-btn { flex: 0 0 auto; }
/* ⚠️ Smaller and quieter than the blurb, but NOT hidden and NOT a tooltip: it is the sentence the
   shopper is agreeing to, and it is stored verbatim with their address. */
.sf-ftr-nl-consent { font-size: 12px; line-height: 18px; margin: 10px 0 0; opacity: .75; }
@media (max-width: 760px) { .sf-ftr-nl-in { flex: 1 1 100%; } }
