/* Cart, checkout, order-complete and account pages — shared by every theme.
   Colours and fonts come from the active theme's CSS custom properties (with fallbacks for
   the internal base theme). Linked from the pages themselves and from the header partial. */

:root {
  --c-primary: var(--color-primary, #0f172a);
  --c-accent: var(--color-accent, #4f46e5);
  --c-bg: var(--color-background, #ffffff);
  --c-text: var(--color-text, #0f172a);
  --c-line: color-mix(in srgb, var(--c-text) 12%, transparent);
  --c-line-strong: color-mix(in srgb, var(--c-text) 25%, transparent);
  --c-soft: color-mix(in srgb, var(--c-text) 5%, var(--c-bg));
  --c-muted: color-mix(in srgb, var(--c-text) 62%, var(--c-bg));
  --c-accent-soft: color-mix(in srgb, var(--c-accent) 12%, var(--c-bg));
  --c-ok: #15803d; --c-ok-bg: #dcfce7; --c-warn: #b45309; --c-warn-bg: #fef3c7;
  --c-danger: #b91c1c; --c-danger-bg: #fee2e2; --c-info: #0369a1; --c-info-bg: #e0f2fe;
  --c-radius: 14px; --c-radius-sm: 10px;
  --c-shadow: 0 10px 30px color-mix(in srgb, var(--c-text) 8%, transparent);
}
/* The *derived* tokens are restated on `body`, and that is not a duplicate of the block above.
   A custom property's `var()`s resolve against the element the property is declared on, so a
   token mixed in `:root` is mixed against `<html>`'s values — while every theme re-points the
   palette from `body.theme-<name>`, one element lower. Each theme restates five of these six by
   hand; `--c-accent-soft` is the one none of the six ever did, so on all six it stayed
   `mix(accent 12%, var(--color-background))` — the mode-static legacy setting — and rendered a
   near-white panel in dark mode under `--c-text` ink that *does* follow the palette. Ratio 1.03
   on the account nav's current item, the applied-discount pill, the download box and the cookie
   panel: the menu item you were standing on was the one you could not read.
   Declared on `body` at (0,0,1) so a theme's own `body.theme-<name>` block still wins. */
body {
  --c-line: color-mix(in srgb, var(--c-text) 12%, transparent);
  --c-line-strong: color-mix(in srgb, var(--c-text) 25%, transparent);
  --c-soft: color-mix(in srgb, var(--c-text) 5%, var(--c-bg));
  --c-muted: color-mix(in srgb, var(--c-text) 62%, var(--c-bg));
  --c-accent-soft: color-mix(in srgb, var(--c-accent) 12%, var(--c-bg));
  --c-shadow: 0 10px 30px color-mix(in srgb, var(--c-text) 8%, transparent);
}
[x-cloak] { display: none !important; }

/* Accessibility utilities the *shared* partials depend on (the search form's label, the
   switchers' labels, the skip link in the page shell). They live here, in the stylesheet every
   theme loads, because a theme that forgot them did not merely look plain — it printed "Search
   products" and "Skip to content" as visible body text on every page. Wrapped in :where() so
   they carry zero specificity and any theme's own rule still wins. */
:where(.sr-only) {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:where(.skip-link) {
  position: absolute; left: 1rem; top: -100px; z-index: 100;
  padding: 0.6rem 1rem; border-radius: var(--c-radius-sm);
  background: var(--c-primary); color: var(--c-bg); font-weight: 600; text-decoration: none;
}
:where(.skip-link):focus { top: 1rem; }
/* ...but zero specificity also loses to a theme's own blanket anchor rule, and every theme has
   one: `body.theme-<name> a { color: inherit }` (0,2,1) beat `:where(.skip-link)` (0,0,0), so
   the link took the body's ink on a background that *is* the body's ink. Ratio 1.00 — the skip
   link was invisible on atelier and marquee at the one moment it exists, when a keyboard user
   has just tabbed onto it. Restated here at a specificity that clears that rule, and on :focus
   because that is the only time it is on screen. A theme that wants its own goes one higher,
   from `body.theme-<name> .skip-link:focus`. */
a.skip-link:focus,
a.skip-link:focus-visible {
  background: var(--c-primary);
  color: var(--c-bg);
}

/* The announcement bar is the theme's to colour (every one of them styles `.announcement`), but
   an announcement that links somewhere is new, and no theme has a rule for the anchor inside it.
   Every theme's blanket `body.theme-<name> a { color: inherit }` already gives it the bar's own
   ink; this only makes it look like a link. */
.announcement a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }

/* A link inside a sentence is only a link if it looks like one.
   Every theme opens with `body.theme-<name> :where(a) { color: inherit; text-decoration: none }`,
   which is right for a menu entry, a product card and a footer column — there, position is what
   says "link" — and wrong for a run of text, where nothing else does. So "By placing this order
   you agree to our Terms and conditions…", "Already have an account? Sign in", the cookie
   notice's links out and a merchant's own prose all read as plain words in a colour a palette
   may well have made identical to the ink around them.

   Scoped to prose: a paragraph, a list item or a definition inside one, plus the rich-text
   containers where a merchant's own words live. `:where(a)` carries no specificity, so one
   class is enough to beat it — the leading `body` is there because a theme may reach the same
   anchor at (0,2,1) and this has to win that too. Anything that is a *control* wearing an
   anchor opts out by class. `.text-link` does not: "Already have an account? Sign in" is the
   same sentence case, and the class is only left alone where it stands on its own in a card or
   a table row, which is not a run of text and is not matched here. */
body :is(p, li, dd) a:not(.btn):not(.btn-outline):not(.btn-accent):not(.pill):not(.ck-btn),
body :is(.product-description, .lesson-body, .policy-doc, .ck-page, .rich-text) a:not(.btn):not(.text-link) {
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-thickness: from-font;
}

/* ---- Page scaffold ---------------------------------------------------------------------- */
.commerce-page { padding: clamp(2rem, 5vw, 3.5rem) 0; }
.commerce-page > .container + .container { margin-top: 2rem; }
.page-head { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.page-head h1 { font-size: clamp(1.75rem, 3.5vw, 2.5rem); }
/* `body `: a theme's `p { margin: 0 }` is (0,1,2) and beat the bare rule, so every account page's
   standfirst sat on its heading. `stacked-text` asks this of a heading since it could not before. */
body .page-head p { color: var(--c-muted); margin-top: 0.35rem; }
.c-muted { color: var(--c-muted); }
.c-small { font-size: 0.875rem; }
.c-flow > * + * { margin-top: 1rem; }
.notice { padding: 0.75rem 1rem; border-radius: var(--c-radius-sm); font-size: 0.925rem; border: 1px solid transparent; }
.notice-ok { background: var(--c-ok-bg); color: var(--c-ok); }
.notice-warn { background: var(--c-warn-bg); color: var(--c-warn); }
.notice-danger { background: var(--c-danger-bg); color: var(--c-danger); }
.notice-info { background: var(--c-info-bg); color: var(--c-info); }
.notice ul { margin: 0; padding-left: 1rem; }
.messages-list { list-style: none; margin: 0 0 1.25rem; padding: 0; display: grid; gap: 0.5rem; }

/* Django's flash messages, emitted by `base.html` for every storefront page and by the delivery
   checkout step. `.store-messages`, `.alert` and `.alert-*` were all defined in exactly one
   place: `static/css/dashboard.css`. So a message that a discount code was rejected, or that an
   address was saved, printed as a bare paragraph in the page's body ink — on all seven themes,
   wherever a view redirected rather than rendering its own copy.

   The signal is carried in the **border**, and the text stays `--c-text` on `--c-soft`, which
   is legible by construction — the same conclusion the pay screens reached.

   `.notice-*` and `.pill-*` below still take a status colour on a tint of itself, which is the
   pairing that measured 2.74:1 to 3.85:1 on the old palettes and 1.51:1 on the one theme that
   never re-pointed `--c-warn-bg`. **That is no longer true**: rebuilding the palettes fixed it,
   and all four pairs now measure 4.51:1 or better on all seven themes
   (`tests/themes/test_status_contrast_browser.py`). Left as they are, because a component that
   passes is not a component to rewrite — but the border-and-body-ink shape above is still the
   safer one for anything new, since it cannot be broken by a palette change. */
.store-messages { display: grid; gap: 0.5rem; margin-block: 1.25rem 0.5rem; }
.alert {
  margin: 0; padding: 0.75rem 1rem;
  border: 1px solid var(--c-line-strong); border-left-width: 3px;
  border-radius: var(--c-radius-sm);
  background: var(--c-soft); color: var(--c-text);
  font-size: 0.925rem;
}
.alert-success { border-left-color: var(--c-ok); }
.alert-info { border-left-color: var(--c-accent); }
.alert-warning { border-left-color: var(--c-warn); }
.alert-error, .alert-danger { border-left-color: var(--c-danger); }
.pill { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.15rem 0.65rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.02em; text-transform: capitalize; background: var(--c-soft); color: var(--c-muted); }
.pill-ok { background: var(--c-ok-bg); color: var(--c-ok); }
.pill-warn { background: var(--c-warn-bg); color: var(--c-warn); }
.pill-danger { background: var(--c-danger-bg); color: var(--c-danger); }
.pill-info { background: var(--c-info-bg); color: var(--c-info); }
.btn-sm { padding: 0.5rem 1rem; font-size: 0.9rem; }
/* A text link. `.btn-link` used to be a second name for this, defined here at weight 600 while
   every theme defines `.text-link` at its own weight and size — so the cart had both on one
   screen and Addresses used one while the order beside it used the other. Two names, two
   renderings, adjacent tabs of the same account. One name now.

   Only the <button> reset lives here: a "Remove" inside a POST form has to look like the "Edit"
   anchor next to it, which is the storefront's version of `a.link` vs `button.link` in the
   admin. Colour and weight stay the theme's. */
button.text-link { background: none; border: 0; padding: 0; font: inherit; cursor: pointer; text-align: inherit; }
/* `body ` is load-bearing. A theme colours `.text-link` from `body.theme-<name> .text-link`,
   which is (0,2,1); a bare `.text-link.danger` is (0,2,0) and loses, so Remove would come out
   the same colour as Edit. With the element selector the two tie and commerce.css, linked after
   the theme, wins. */
/* The palette's `danger` role first: `--c-danger` is a mode-static `#b91c1c`, which is 2.8:1 on a
   dark ground, and the basket's Remove link was that on ten themes. Nothing had measured it,
   because the contrast sweep's customer had an empty basket. */
body .text-link.danger { color: var(--color-danger, var(--c-danger)); }
.btn.btn-full { width: 100%; }

/* ---- Shared form furniture ---------------------------------------------------------------

   `.card`, `.field` and `.input` are reached for by shared storefront templates that no theme
   overrides: the contact form, the support thread's reply box, the review form, the delivery
   checkout step and the tracking page. Every one of them is a real, well-styled class — in
   `static/css/dashboard.css`, the **admin** stylesheet, which no storefront page ever loads.
   Exactly the mistake the learning templates made, in five new places: the contact form was a
   browser-default stack of unstyled boxes on all seven themes from the day `apps/support`
   shipped.

   The sweep that exists to catch this could not see it. `_defined_classes()` pulled class names
   out of the *whole* stylesheet text, comments included, and both names appear in prose — `.card`
   in the block further down this file, `.input` in `static/css/richtext.css`. Two sentences of
   documentation were standing in for two components.

   `.field` is deliberately bare, not `.form .field`: the contact and reply forms carry `.card`,
   not `.form`, so the themes' own `.form .field` rules (all at higher specificity, so they still
   win where they apply) never reached them. */
.card { padding: 1.5rem; border: 1px solid var(--c-line); border-radius: var(--c-radius); background: var(--c-bg); }
.field { display: grid; gap: 0.4rem; margin-bottom: 1.15rem; }
.field > label { font-weight: 600; font-size: 0.9rem; }
.input {
  width: 100%; padding: 0.7rem 0.9rem;
  border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
  background: var(--c-bg); color: inherit; font: inherit;
}
textarea.input { resize: vertical; min-height: 5rem; }
.input:focus-visible {
  outline: none; border-color: var(--c-accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 35%, transparent);
}
.input::placeholder { color: var(--c-muted); opacity: 1; }

/* ---- Two-column commerce layout (cart, checkout) ------------------------------------------ */
.commerce-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(18rem, 1fr); gap: 2.5rem; align-items: start; }
.summary-card { position: sticky; top: calc(var(--aurora-header-h, 4.25rem) + 1rem); padding: 1.5rem; border: 1px solid var(--c-line); border-radius: var(--c-radius); background: var(--c-bg); box-shadow: var(--c-shadow); }
/* `body ` on all three: a heading's margin is the space under it, and every theme resets
   `h2`/`h3 { margin: 0 }` at (0,1,2) — so `.summary-card h2` at (0,1,1) is a gap that is
   written and never applied. The Review heading sat on its own first line, the checkout's
   section headings on their standfirsts, and a basket line's name on its own size and
   colour, on every theme, for as long as those screens have existed. Nothing could see it
   until the geometry sweep was given a basket: `/cart/` and `/checkout/` were both crawled
   with an empty one, so neither a cart line nor a summary had ever been drawn. */
body .summary-card h2 { font-size: 1.15rem; margin-bottom: 1rem; }
.totals { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; font-size: 0.95rem; }
.totals li { display: flex; justify-content: space-between; gap: 1rem; }
.totals .grand { margin-top: 0.5rem; padding-top: 0.85rem; border-top: 1px solid var(--c-line); font-weight: 700; font-size: 1.1rem; }
.totals .discount { color: var(--c-ok); }
.totals .hint { color: var(--c-muted); font-size: 0.8rem; }
.discount-form { display: flex; gap: 0.5rem; margin-top: 1.25rem; }
.discount-form input { flex: 1; min-width: 0; }
.discount-applied { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-top: 1.25rem; padding: 0.6rem 0.85rem; border-radius: var(--c-radius-sm); background: var(--c-accent-soft); font-size: 0.9rem; }

/* ---- Cart lines ----------------------------------------------------------------------------- */
.cart-lines { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--c-line); }
.cart-line { display: grid; grid-template-columns: 5rem minmax(0, 1fr) auto; gap: 1.25rem; align-items: center; padding: 1.25rem 0; border-bottom: 1px solid var(--c-line); }
.cart-thumb { width: 5rem; aspect-ratio: 1; border-radius: var(--c-radius-sm); background: var(--c-soft); overflow: hidden; display: grid; place-items: center; color: var(--c-muted); }
.cart-thumb img { width: 100%; height: 100%; object-fit: cover; }
body .cart-line-body h3 { font-size: 1rem; margin-bottom: 0.15rem; }
.cart-line-body h3 a { text-decoration: none; }
.cart-line-meta { font-size: 0.85rem; color: var(--c-muted); margin-top: 0.15rem; }
.cart-line-actions { display: flex; align-items: center; gap: 1rem; margin-top: 0.75rem; flex-wrap: wrap; }
.qty { display: inline-flex; align-items: center; border: 1px solid var(--c-line-strong); border-radius: 999px; overflow: hidden; }
.qty button { width: 2.25rem; height: 2.25rem; border: 0; background: transparent; cursor: pointer; font-size: 1.1rem; color: var(--c-text); }
.qty button:hover { background: var(--c-soft); }
.qty input { width: 3rem; text-align: center; border: 0; background: transparent; padding: 0.4rem 0; font-weight: 600; -moz-appearance: textfield; }
.qty input::-webkit-outer-spin-button, .qty input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.cart-line-price { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.cart-line-price small { display: block; font-weight: 400; color: var(--c-muted); }
.cart-actions { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-top: 1.25rem; flex-wrap: wrap; }

/* ---- Empty state ---------------------------------------------------------------------------- */
.empty-state { text-align: center; padding: clamp(3rem, 8vw, 5rem) 1rem; border: 1px dashed var(--c-line-strong); border-radius: var(--c-radius); }
.empty-state .icon { width: 3rem; height: 3rem; margin: 0 auto 1rem; color: var(--c-muted); }
/* `body ` ties a theme's `h2`/`p` margin reset and wins by being linked later; without it the
   "Browse the shop…" sentence rested on the Start shopping button on every theme. */
body .empty-state h2 { margin-bottom: 0.5rem; }
body .empty-state p { color: var(--c-muted); margin-bottom: 1.5rem; }

/* ---- Mini-cart drawer ----------------------------------------------------------------------- */
.drawer-backdrop { position: fixed; inset: 0; z-index: 80; background: color-mix(in srgb, var(--c-text) 45%, transparent); }
.cart-drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 90; width: min(26rem, 100%); background: var(--c-bg); box-shadow: var(--c-shadow); display: flex; flex-direction: column; }
.cart-drawer-head { display: flex; align-items: center; justify-content: space-between; padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--c-line); }
.cart-drawer-head h2 { font-size: 1.15rem; }
.cart-drawer-close { border: 0; background: transparent; width: 2.25rem; height: 2.25rem; border-radius: 999px; cursor: pointer; font-size: 1.4rem; line-height: 1; color: var(--c-text); }
.cart-drawer-close:hover { background: var(--c-soft); }
.cart-drawer-body { flex: 1; overflow-y: auto; padding: 0 1.5rem; }
.cart-drawer-body .cart-line { grid-template-columns: 3.5rem minmax(0, 1fr) auto; gap: 0.85rem; padding: 1rem 0; }
.cart-drawer-body .cart-thumb { width: 3.5rem; }
.cart-drawer-foot { padding: 1.25rem 1.5rem; border-top: 1px solid var(--c-line); display: grid; gap: 0.75rem; }
.cart-drawer-foot .subtotal { display: flex; justify-content: space-between; font-weight: 700; }
.cart-drawer-foot .btn { width: 100%; }
.cart-drawer .empty-state { border: 0; padding: 3rem 1rem; }

/* ---- Checkout --------------------------------------------------------------------------------- */
.checkout-steps { display: flex; gap: 0.5rem; list-style: none; margin: 0 0 2rem; padding: 0; counter-reset: step; flex-wrap: wrap; }
.checkout-steps li { counter-increment: step; display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; color: var(--c-muted); }
.checkout-steps li::before { content: counter(step); display: grid; place-items: center; width: 1.5rem; height: 1.5rem; border-radius: 999px; background: var(--c-soft); font-size: 0.75rem; }
.checkout-steps li.active { color: var(--c-text); }
/* `--c-bg`, not `#fff`: `--c-primary` is the palette's ink, so in dark mode it is near-white and a
   literal white numeral on it vanished — every step read as an empty light dot. The sweep only
   measured text *nodes*, and a counter is `::before` content; it measures generated content now. */
.checkout-steps li.active::before { background: var(--c-primary); color: var(--c-bg); }
/* `.active` is "reached", which a done step and the current one both are — so without this the
   step you are standing on looks exactly like the ones behind it, which is most of what a progress
   bar is for. Weight rather than a colour: the pair is already an AA-guaranteed one, and a third
   shade of it is a contrast figure nobody has measured. `aria-current="step"` carries the same
   fact for anybody who cannot see either. */
.checkout-steps li.is-current { font-weight: 800; }
.checkout-steps li + li { margin-left: 0.75rem; }
.checkout-section { padding: 1.5rem; border: 1px solid var(--c-line); border-radius: var(--c-radius); margin-bottom: 1.5rem; }
body .checkout-section h2 { font-size: 1.15rem; margin-bottom: 0.25rem; }
.checkout-section .lede { color: var(--c-muted); font-size: 0.9rem; margin-bottom: 1.25rem; }
.checkout-section .signed-in { font-size: 0.9rem; color: var(--c-muted); margin-bottom: 1rem; }
/* The address map (`static/js/address_locator.js`). Its actions row is written by the script, so
   with JavaScript off the block is the map (or the consent gate) and nothing that looks pressable.
   `body p.x` because every theme resets `p { margin: 0 }` at (0,1,2). Leaflet's tiles are light
   whatever the scheme, so the frame takes the palette's line and the page keeps its own ground. */
.address-locator { display: grid; gap: 0.6rem; margin-bottom: 1.25rem; }
body p.address-locator-intro { margin: 0; font-size: 0.9rem; color: var(--c-muted); }
.address-map { height: 17rem; border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm); overflow: hidden; z-index: 0; }
body .address-locator > p.hint { margin: 0; font-size: 0.8rem; color: var(--c-muted); }
.address-locator-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem; }
body p.address-locator-status { margin: 0; font-size: 0.85rem; color: var(--c-text); flex: 1 1 14rem; min-width: 0; }
body p.address-locator-status:empty { display: none; }
@media (max-width: 600px) { .address-map { height: 14rem; } }
/* Searching for the address (the same script). The box, its label and the results are all built
   by `address_locator.js` and appear only where the shop has a geocoder, so nothing here is a
   hidden state the page starts in — the `reveal.js` rule, as on the header's search panel, whose
   highlight and ground this copies rather than inventing a second look for one component. */
.address-search { position: relative; display: grid; gap: 0.35rem; }
.address-search-label { font-size: 0.85rem; font-weight: 600; }
.address-search-input { width: 100%; }
.address-search-panel {
  position: absolute;
  inset-inline: 0;
  top: calc(100% + 0.3rem);
  /* Over the map, which Leaflet gives its own stacking context. */
  z-index: 60;
  max-height: min(60vh, 20rem);
  overflow-y: auto;
  padding: 0.3rem;
  background: var(--color-surface, #fff);
  color: var(--color-ink, #0f172a);
  border: 1px solid var(--color-border, #9ca3af);
  border-radius: var(--c-radius, 8px);
  box-shadow: 0 14px 34px rgb(0 0 0 / 22%);
}
.address-search-list { list-style: none; margin: 0; padding: 0; }
.address-search-option {
  padding: 0.5rem 0.6rem;
  border-radius: calc(var(--c-radius, 8px) - 2px);
  font-size: 0.9rem;
  cursor: pointer;
}
.address-search-option.is-active {
  background: color-mix(in srgb, var(--color-ink, #0f172a) 9%, var(--color-surface, #fff));
}
body p.address-search-empty { margin: 0; padding: 0.5rem 0.6rem; font-size: 0.85rem; color: var(--c-muted); }
/* The search's own message, under its own box. The shared `.address-locator-status` sits below
   the map beside *Use my location*, so a sentence about this box landed a screen away from it. */
body p.address-search-note { margin: 0; font-size: 0.85rem; color: var(--c-text); }
/* The local-delivery step's own map (`storefront/delivery/checkout_step.html`). It carried its
   height and radius as an inline `style=`, which is a rule no sweep in this project can read —
   the same reason `payments/_styles.html` had to become a real stylesheet. With the pin already
   dropped on checkout's address map the page states where we are coming instead of asking again,
   and folds this away behind a disclosure; Leaflet needs `invalidateSize()` when that opens. */
.pick-map { height: 21rem; border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm); overflow: hidden; z-index: 0; }
@media (max-width: 600px) { .pick-map { height: 16rem; } }
/* Spaced with `gap`, not with margins on the headings: every theme resets `h2` and `p` to
   `margin: 0` at (0,1,2), so a one-class margin here is a rule that is in the file and never
   applies — the `stacked-text` lesson, on a page no geometry sweep reaches, because rendering it
   takes a placed order. `body .card.delivery-step` to tie a theme's own `.card` rule and win by
   being linked later; the hidden inputs and the CSRF field are `display: none` and so cost no gap. */
body .card.delivery-step { display: grid; gap: 0.75rem; }
body .delivery-step h2 { font-size: 1.15rem; margin: 0.75rem 0 0; }
body .delivery-step h2:first-of-type { margin-top: 0; }
body address.pin-known-address { margin: 0; font-style: normal; line-height: 1.5; }
/* The summary carries `.text-link` for its colour and weight, which is the theme's to decide —
   inventing one here is how two controls that do the same job end up looking unrelated. */
.pin-adjust > summary { cursor: pointer; width: fit-content; padding: 0.15rem 0; font-size: 0.9rem; }
.pin-adjust[open] > summary { margin-bottom: 0.6rem; }
.form .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
.form select, .form textarea { width: 100%; padding: 0.7rem 0.9rem; border-radius: var(--c-radius-sm); border: 1px solid var(--c-line-strong); background: var(--c-bg); font: inherit; color: inherit; }
/* …and the text inputs beside them, which this file never styled: six themes fill the gap with
   a `.form input:not([type=checkbox])` rule of their own and `summit` does not, so on that theme
   every address and register field was a browser-default box next to a styled select. Wrapped in
   `:where()` — zero specificity — because those six rules and this one are otherwise the same
   selector, and commerce.css is linked last, so a plain copy here would silently take the look
   of every checkout form away from the theme that owns it. */
.form :where(input:not([type="checkbox"]):not([type="radio"]):not([type="submit"])) {
  width: 100%; padding: 0.7rem 0.9rem; border-radius: var(--c-radius-sm);
  border: 1px solid var(--c-line-strong); background: var(--c-bg); font: inherit; color: inherit;
}
.form textarea { resize: vertical; min-height: 5rem; }
.form select:focus-visible, .form textarea:focus-visible { outline: none; border-color: var(--c-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 35%, transparent); }
/* `.card` too: the contact page's form is a card, not a `.form`, and asks the newsletter question. */
:is(.form, .card) .field-check { display: flex; align-items: start; gap: 0.6rem; margin-bottom: 1rem; }
:is(.form, .card) .field-check label { font-weight: 500; }
:is(.form, .card) .field-check input { width: 1.05rem; height: 1.05rem; margin-top: 0.2rem; accent-color: var(--c-accent); }
.form .field .hint { font-size: 0.8rem; color: var(--c-muted); }
.form .field-error, .form .errorlist { list-style: none; margin: 0; padding: 0; font-size: 0.85rem; color: var(--c-danger); }
.form .has-error input, .form .has-error select { border-color: var(--c-danger); }
.form .btn { width: auto; margin-top: 0; }

/* ---- Every select in the shop, once ---------------------------------------------------------
   `appearance: none` is the whole rule and it was written in exactly one place: the two header
   switchers, where its absence had already been found once. Everywhere else a `<select>` was
   given a border, a background and a font by a component rule of its own and then painted by the
   **operating system** anyway — grey, rounded, system font, native arrow — on whatever palette
   the merchant chose. Four separate rules had made the same omission: `.form select` above (so
   the province and country boxes on an address), the reason on a return, the three filters on
   `/experiences/`, and `.evt-book-select` — *the date picker a repeating event is booked
   through*, i.e. the control a purchase is made with.

   One rule rather than a fifth, sixth and seventh copy. That is the lesson the currency switcher
   is already the record of: six themes each wrote a private copy of those four rules and the
   seventh wrote none. A component that four rules describe identically has one owner.

   The two switchers are excluded on purpose and the exclusion is not new — several themes pad
   for the native arrow and draw their own chevron over it, so suppressing it from here would
   leave them with neither.

   `body ` is load-bearing. A theme styles its form controls from `body.theme-<name> .form select`
   at (0,2,2), and a bare `select:not()…` is (0,2,1) — so the theme's `background` *shorthand*
   would win and reset `background-image` to none, leaving a select with no arrow at all. At
   (0,2,2) the two tie and commerce.css is linked last. The same load-bearing `body ` that
   `.container.narrow` and `.text-link.danger` needed.

   The chevron is two gradients in `currentColor`, not a background image: a `<select>` cannot
   carry a pseudo-element, and a data-URI SVG would be a colour literal in a file that forbids
   them and would not follow the palette into dark mode. */
body select:not(.currency-select):not(.locale-select) {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 2.1rem;
  color: var(--c-text);
  background-image:
    linear-gradient(45deg, transparent 50%, currentColor 50%),
    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-size: 5px 5px, 5px 5px;
  background-position: right 1.15rem center, right 0.8rem center;
  background-repeat: no-repeat;
}
.option-list { display: grid; gap: 0.6rem; }
.option-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 0.85rem; padding: 0.85rem 1rem; border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm); cursor: pointer; font-weight: 400; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.option-card input { width: 1.05rem; height: 1.05rem; accent-color: var(--c-accent); margin: 0; }
.option-card:has(input:checked) { border-color: var(--c-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 25%, transparent); }
.option-card strong { display: block; }
/* `display: block` because a delivery method prints two of these — its lead time and its
   own description — and inline they are one run of text: "Choose a time slot at
   checkoutWe deliver it ourselves". `.pay-method .desc` in `payments.css` is the same
   control one screen further along the same checkout and has always had it. */
.option-card .desc { display: block; font-size: 0.85rem; color: var(--c-muted); }
.option-card .amount { font-weight: 700; white-space: nowrap; }
.option-card .amount.free { color: var(--c-ok); }
.checkout-summary .summary-lines { list-style: none; margin: 0 0 1rem; padding: 0; display: grid; gap: 0.6rem; font-size: 0.9rem; }
.checkout-summary .summary-lines li { display: flex; justify-content: space-between; gap: 1rem; }
.checkout-summary .summary-lines .name { color: var(--c-muted); }
.checkout-summary .summary-lines .name strong { color: var(--c-text); }
/* The button, the legal sentence and the "you'll choose how to pay" line, under the totals.
   Spaced with `gap` rather than margins: every theme resets `p { margin: 0 }` at (0,1,2), so
   `.secure-note`'s own `margin-top` was a rule that never applied and all three sat on each
   other. `.policy-notice` (`policies/_checkout_notice.html`) and `.currency-notice` had no rule
   at all in any stylesheet. */
.place-order { margin-top: 1.25rem; display: grid; gap: 0.85rem; }
.place-order .btn { width: 100%; }
.place-order .policy-notice,
.place-order .currency-notice {
  margin: 0; font-size: 0.8rem; line-height: 1.55; color: var(--c-muted); text-align: center;
}
.place-order .policy-notice a, .place-order .currency-notice strong { color: var(--c-text); }
/* What `static/js/turnstile.js` says when a press is held back — it builds the element, so no
   template can be swept for this class. `body p.…` because every theme resets `p { margin: 0 }`
   at (0,1,2); the ink is the page's own rather than `--c-warn`, which this file has already
   measured at 1.51:1 on its own tint, and the weight is what makes it read as an instruction. */
body p.turnstile-note {
  margin: 0.35rem 0 0; font-size: 0.85rem; font-weight: 500; line-height: 1.5; color: var(--c-text);
}
body .place-order p.turnstile-note { margin: 0; text-align: center; }
/* Cloudflare's `flexible` widget fills its container and will not draw narrower than 300px, and
   the bot check is in this column now. The shared summary track is `minmax(18rem, …)` — 288px,
   twelve short — so the checkout grid asks for a little more. No sweep can see this: the widget is
   a Cloudflare iframe that never loads in a test. */
body .commerce-grid.checkout-grid { grid-template-columns: minmax(0, 1.6fr) minmax(19rem, 1fr); }
@media (max-width: 900px) { body .commerce-grid.checkout-grid { grid-template-columns: 1fr; } }
.secure-note { display: flex; align-items: center; gap: 0.5rem; justify-content: center; margin: 0; font-size: 0.8rem; color: var(--c-muted); }
.htmx-request .htmx-indicator { opacity: 1; }
.htmx-indicator { opacity: 0; transition: opacity 0.2s ease; font-size: 0.8rem; color: var(--c-muted); }

/* ---- Order complete / detail ------------------------------------------------------------- */
.thanks { text-align: center; max-width: 40rem; margin: 0 auto 2.5rem; }
.thanks .check { width: 4rem; height: 4rem; border-radius: 999px; display: grid; place-items: center; margin: 0 auto 1.25rem; background: var(--c-ok-bg); color: var(--c-ok); }
.thanks .check .icon { width: 2rem; height: 2rem; }
.thanks h1 { margin-bottom: 0.5rem; }
.thanks p { color: var(--c-muted); }
/* (0,2,1): a theme's `p { margin: 0 }` reset is (0,1,2). */
.thanks p.offline-note { margin-top: 0.75rem; color: var(--c-text); }
.thanks .actions { display: flex; gap: 0.75rem; justify-content: center; margin-top: 1.5rem; flex-wrap: wrap; }
.order-panel { border: 1px solid var(--c-line); border-radius: var(--c-radius); padding: 1.5rem; }
/* `body ` ties a theme's `h2`/`p` margin reset at (0,1,2) and wins by being linked later —
   without it every panel heading (Tracking, a booking's service name, "Changed your mind?")
   rested on the box or button under it. `stacked-text` in `tests/system/geometry.py`. */
body .order-panel h2 { font-size: 1.1rem; margin-bottom: 1rem; }
body .order-panel p + p { margin-top: 0.75rem; }
body .card + p, body .order-panel + p { margin-top: 1rem; }
/* A form's own heading over its first field — "Send us a message" on the contact page. */
body :is(h2, h3) + .field { margin-top: 1rem; }
.order-panel + .order-panel { margin-top: 1.5rem; }
.order-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(10rem, 100%), 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.order-meta dt { font-size: 0.75rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--c-muted); margin-bottom: 0.2rem; }
.order-meta dd { margin: 0; font-weight: 600; }
.address-block { font-style: normal; line-height: 1.5; font-size: 0.95rem; }

/* ---- Returning something (apps/returns/templates/returns/request_return.html) --------------

   Shared markup no theme overrides, so it is styled once here — the arrangement the product
   sections, the course pages and the booking panel all arrived at. It was written in the
   *admin's* classes (`card`, `card-header`, `card-section`, `input-label`, `btn-primary`) with
   `#ddd` / `#666` / `#f5f5f5` inline beside them, so it rendered as browser defaults on all
   seven themes and its panels were mode-static grey in dark mode. Everything below is on the
   `--c-*` roles, which each theme re-points. */
.return-lines { list-style: none; margin: 0; padding: 0; display: grid; gap: 1rem; }
.return-item {
  border: 1px solid var(--c-line); border-radius: var(--c-radius-sm);
  padding: 1rem; display: grid; gap: 0.5rem;
}
.return-item-head { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; }
.return-item-meta { margin: 0; color: var(--c-muted); font-size: 0.9rem; }
/* The quantity box is one number, not a full-width field: `.field > .input` is 100% wide, and
   a 12rem cap keeps it reading as "how many" rather than as another address line. */
.return-item-qty { margin-bottom: 0; max-width: 12rem; }
.form-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1.5rem; }
.order-lines { list-style: none; margin: 0; padding: 0; }
.order-lines li { display: flex; justify-content: space-between; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid var(--c-line); font-size: 0.95rem; }
.order-lines li:last-child { border-bottom: 0; }
.order-lines .qty-label { color: var(--c-muted); }
.download-box { padding: 1rem 1.25rem; border-radius: var(--c-radius-sm); background: var(--c-accent-soft); font-size: 0.925rem; }
.download-box ul { margin: 0.5rem 0 0; padding-left: 1.1rem; }
.tracking { display: grid; gap: 0.5rem; }
.tracking-item { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; padding: 0.75rem 1rem; border-radius: var(--c-radius-sm); background: var(--c-soft); font-size: 0.925rem; }

/* ---- Account -------------------------------------------------------------------------------- */
.account-layout { display: grid; grid-template-columns: 14rem minmax(0, 1fr); gap: 2.5rem; align-items: start; }
.account-nav { display: flex; flex-direction: column; gap: 0.25rem; }
.account-nav a { padding: 0.6rem 0.9rem; border-radius: var(--c-radius-sm); text-decoration: none; color: var(--c-muted); font-weight: 500; }
.account-nav a:hover { background: var(--c-soft); color: var(--c-text); }
.account-nav a[aria-current="page"] { background: var(--c-accent-soft); color: var(--c-text); font-weight: 600; }
.account-nav form { margin-top: 0.5rem; }
.account-nav button { width: 100%; text-align: left; padding: 0.6rem 0.9rem; border: 0; background: transparent; color: var(--c-muted); font: inherit; font-weight: 500; cursor: pointer; border-radius: var(--c-radius-sm); }
.account-nav button:hover { background: var(--c-soft); color: var(--c-text); }
.order-table { width: 100%; border-collapse: collapse; font-size: 0.925rem; }
.order-table th { text-align: left; font-size: 0.75rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--c-muted); padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--c-line); }
.order-table td { padding: 0.85rem 0.75rem; border-bottom: 1px solid var(--c-line); vertical-align: middle; }
.order-table a { font-weight: 600; text-decoration: none; }
.order-table a:hover { text-decoration: underline; }
.table-scroll { overflow-x: auto; }
.address-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr)); gap: 1rem; }
.address-card { border: 1px solid var(--c-line); border-radius: var(--c-radius); padding: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
.address-card .pill { align-self: start; }
.address-card .card-actions { display: flex; gap: 1rem; margin-top: auto; font-size: 0.9rem; }
.auth-card.wide { max-width: 32rem; }

/* ---- Responsive -------------------------------------------------------------------------- */
@media (max-width: 900px) {
  .commerce-grid { grid-template-columns: 1fr; gap: 2rem; }
  .summary-card { position: static; }
  /* `minmax(0, 1fr)`, not `1fr`. A grid track's minimum is `auto`, which means *min-content*,
     and `.table-scroll` cannot scroll while the track it is in is happy to grow — so the order
     table's 456px min-content became the width of the column, the column became wider than the
     page, and `/account/` scrolled sideways by 86px on a phone. The wide layout above already
     writes `minmax(0, 1fr)` for exactly this reason; the stacked one was the same declaration
     with the fix left off. It is the page builder's `min-height: 0` lesson turned on its side:
     **every ancestor between the scroller and the edge has to be allowed to be smaller than its
     content**, or the scroller never scrolls and the page does instead. */
  .account-layout { grid-template-columns: minmax(0, 1fr); gap: 1.5rem; }
  .account-nav { flex-direction: row; flex-wrap: wrap; }
}
@media (max-width: 600px) {
  .cart-line { grid-template-columns: 4rem minmax(0, 1fr); }
  .cart-line-price { grid-column: 2; text-align: left; }
  .form .field-row { grid-template-columns: 1fr; }
  .checkout-section { padding: 1.15rem; }
  .thanks .actions .btn { width: 100%; }
}

/* ---------------------------------------------------------------- header search
   Shared by every theme (partials/_search_form.html). Token-driven so it inherits the
   merchant's palette; `flex: 1` with a max-width is what centres it between the brand and
   the header actions without needing a grid the themes do not share.

   **The form is the box and the input is the text.** That is the contract, and for a long time
   it had two owners instead: this rule painted a bordered, rounded, `--color-surface` box, and
   every one of the seven themes painted a *second* box on `.site-search-input` inside it —
   which wins, being `body.theme-<name> .site-search-input` at (0,2,1). Measured in Chromium,
   what shipped was a 16px-radius rectangle around a 999px pill (harbour), 22px around 999px
   (petal), a full border wrapped around a bare underline field (press, studio), a doubled
   border on the attached submit button (depot), the magnifier stranded outside the pill on all
   six, and every theme's chosen width overridden by the `flex: 1` below.

   Worse on `voyage`, whose header is dark and whose field is deliberately a translucent wash on
   it: this rule's `--color-surface` ground went *under* that wash, so voyage's near-white input
   ink sat on near-white and **what you typed measured 1.0:1 — in both colour schemes**. The
   same shape as `--c-accent-soft` mixed in the wrong scope: markup and CSS both exactly as
   written, nothing empty, nothing 500ing, and only a browser can see it.

   So a theme restyles `.site-search` and leaves the input bare; a theme that wants a real
   attached submit button (depot) says so on `.site-search-button`.
   `tests/themes/test_search_box_browser.py` measures both halves on all seven, in both modes. */
.site-search {
  /* The suggestions panel is absolutely positioned against this. */
  position: relative;
  display: flex;
  align-items: center;
  gap: 0;
  flex: 1 1 auto;
  max-width: 26rem;
  margin-inline: auto;
  border: 1px solid var(--color-border, #9ca3af);
  border-radius: var(--c-radius, 8px);
  background: var(--color-surface, #fff);
  /* No `overflow: hidden`: it clipped nothing once the input stopped carrying its own corners,
     and it would clip the suggestions panel to the height of the box it drops out of. */
}
.site-search:focus-within {
  border-color: var(--color-accent, #4f46e5);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent, #4f46e5) 25%, transparent);
}
.site-search-input {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0.5rem 0.75rem;
  border: 0;
  background: transparent;
  color: var(--color-ink, #0f172a);
  font: inherit;
  font-size: 0.95rem;
}
.site-search-input::placeholder { color: var(--color-ink-muted, #475569); opacity: 1; }
.site-search-input:focus { outline: none; }
/* Chrome's default clear button is invisible on a dark palette. */
.site-search-input::-webkit-search-cancel-button { filter: invert(0.5); }
.site-search-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.5rem 0.7rem;
  border: 0;
  background: transparent;
  color: var(--color-ink-muted, #475569);
  cursor: pointer;
}
/* Muted ink to the header's **own** ink, and `inherit` rather than a named token.
   It was `var(--color-accent)`: an accent is a *fill* colour and several themes' do not
   carry as small text (studio's mid red measures 3.98:1 on its own header). But naming
   `--color-ink` instead is the same mistake pointed the other way — vault's header is a
   dark band in *both* modes, so the page's ink is dark-on-dark there. `inherit` is the
   only answer that is right for a rule shared by seven headers: it takes whatever colour
   that header sets, which each theme has already had to get right. */
/* **Tint the ground, do not move the ink.** This rule is shared by seven headers and
   every named colour has a theme it is wrong on: `var(--color-accent)` is a *fill*
   colour that several accents cannot carry as small text (studio measured 3.98:1),
   `var(--color-ink)` is dark-on-dark wherever a header is a dark band in both modes
   (vault), and `inherit` picks up the wrong parent where the search sits in its own
   input. The ink at rest is already readable — whatever the theme made it — so the
   affordance is a tint of `currentColor` behind it, which is a tint of exactly that
   ink and therefore cannot collide with it. */
.site-search-button:hover { background: color-mix(in srgb, currentColor 14%, transparent); }
.site-search-icon { width: 20px; height: 20px; }

/* The results page's own box: the same component one size up, on its own line rather than
   squeezed between a brand and a row of icons. It used to be a hand-written `<form>` in each of
   six themes' `search.html`, wearing `.newsletter-form` — a *newsletter signup* layout — around
   an unclassed `<input type="search">`, with the margin inline-styled. Six copies of one control
   is how a seventh ships with none, and none of the six could have carried the panel below.

   Written `body .site-search.site-search-page`, and every part of that is load-bearing. A
   theme sizes its *header* box with `body.theme-<name> .site-search` at (0,2,1), so a bare
   `.site-search-page` (0,1,0) — and even `.site-search.site-search-page` (0,2,0) — loses to
   it, and the page box silently comes out at the header's 12–15rem. The leading `body` ties
   the specificity, and `commerce.css` is linked *after* the theme stylesheet, so it wins on
   order. Exactly why `.container.narrow` had to be written `body .container.narrow`. A theme
   that wants a different page box says `body.theme-<name> .site-search.site-search-page`,
   which press, petal and voyage do. */
body .site-search.site-search-page {
  max-width: 34rem;
  /* Aligned with the heading above it, which on five of the seven themes starts at the gutter.
     `.site-search` centres itself with `margin-inline: auto` because in a header it is a flex
     item between the brand and the icons; on a page it is an ordinary block and inheriting that
     would leave it floating under a left-aligned `<h1>`. voyage and petal centre their listing
     hero and centre this with it — a theme's alignment belongs in the theme. */
  margin-inline: 0;
  margin-block-start: 1.1rem;
}
/* Three classes, for the same reason and against `body.theme-<name> .site-search-input`. */
body .site-search.site-search-page .site-search-input { padding: 0.7rem 1rem; font-size: 1rem; }
body .site-search.site-search-page .site-search-button { padding: 0.7rem 0.9rem; }

/* On narrow screens the box drops onto its own row rather than crushing the brand. */
@media (max-width: 720px) {
  .site-search { order: 10; flex-basis: 100%; max-width: none; margin-block-start: 0.6rem; }
}
@media (prefers-reduced-motion: reduce) {
  .site-search, .site-search-button { transition: none; }
}

/* ------------------------------------------------- search suggestions and recent searches
   Built by `static/js/search.js`, which is why none of this markup is in a template: a
   `role="listbox"` with no script behind it promises a keyboard user something that will never
   open. Nothing here is a hidden state the page starts in — the panel does not exist until the
   script makes it — so a blocked or broken script leaves an ordinary GET form, which is the
   whole point (the `reveal.js` rule).

   Owned here rather than per theme for the reason the currency and language pickers are: a
   per-item rule is a thing the eighth theme forgets. A theme tweaks from `body.theme-<name>`. */
.site-search-panel {
  position: absolute;
  /* Anchored to the box's *end* edge and allowed to be wider than it, growing inwards. Five of
     the seven headers give the field 12–15rem, which is fine to type in and far too narrow to
     read a product name in: pinned to the box's width, "Enamel Camp Mug" came out as "Ena…" on
     voyage. Growing from the start edge instead would push it off the side of the page on the
     themes whose box sits hard right. */
  inset-inline-start: auto;
  inset-inline-end: 0;
  width: max(100%, 22rem);
  max-width: 92vw;
  top: calc(100% + 0.4rem);
  /* Above the header's own contents and anything sticky under it, below a modal. */
  z-index: 60;
  max-height: min(70vh, 26rem);
  overflow-y: auto;
  padding: 0.3rem;
  text-align: start;
  background: var(--color-surface, #fff);
  color: var(--color-ink, #0f172a);
  border: 1px solid var(--color-border, #9ca3af);
  border-radius: var(--c-radius, 8px);
  box-shadow: 0 14px 34px rgb(0 0 0 / 22%);
}
.site-search-group + .site-search-group { border-top: 1px solid var(--color-border, #9ca3af); }
.site-search-group-title {
  margin: 0.45rem 0.6rem 0.25rem;
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-ink-muted, #475569);
}
.site-search-options { list-style: none; margin: 0; padding: 0; }
.site-search-option {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  padding: 0.45rem 0.6rem;
  border-radius: calc(var(--c-radius, 8px) - 2px);
  cursor: pointer;
}
/* The highlight is a mix of the palette's *own* pair, never a colour of this file's choosing:
   the palettes guarantee ink-on-surface and guarantee nothing about anything else, which is how
   `--c-warn` on `--c-warn-bg` reached 1.51:1. */
.site-search-option.is-active {
  background: color-mix(in srgb, var(--color-ink, #0f172a) 9%, var(--color-surface, #fff));
}
.site-search-thumb {
  flex: 0 0 auto;
  width: 2.5rem;
  height: 2.5rem;
  object-fit: cover;
  border-radius: calc(var(--c-radius, 8px) - 3px);
  background: color-mix(in srgb, var(--color-ink, #0f172a) 6%, var(--color-surface, #fff));
}
.site-search-option-body { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
.site-search-option-title {
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.site-search-option-meta {
  font-size: 0.8rem;
  color: var(--color-ink-muted, #475569);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.site-search-option-price {
  margin-inline-start: auto;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.site-search-recent-icon { color: var(--color-ink-muted, #475569); }
/* Deliberately the page's own ink rather than `--color-accent`: an accent is a colour to *fill*
   with and carries `--color-accent-ink` on it, and using it as text on the surface is the
   `--brand-600` / `--brand-ink` split the admin had to make. Weight and a rule do the work. */
.site-search-more { justify-content: center; font-weight: 600; }
.site-search-empty {
  margin: 0;
  padding: 0.9rem 0.6rem;
  text-align: center;
  color: var(--color-ink-muted, #475569);
}
.site-search-clear {
  display: block;
  width: 100%;
  margin-top: 0.2rem;
  padding: 0.5rem;
  border: 0;
  border-top: 1px solid var(--color-border, #9ca3af);
  background: transparent;
  color: var(--color-ink-muted, #475569);
  font: inherit;
  font-size: 0.82rem;
  text-align: center;
  cursor: pointer;
}
.site-search-clear:hover { color: var(--color-ink, #0f172a); }

/* ---------------------------------------------------------------- currency & language pickers
   `partials/_currency_switcher.html` and `_language_switcher.html`, beside the search box in
   every theme's header and overridden by none of them — the same shape as `.site-search` above,
   and it should have been owned here from the start. Instead six themes each wrote their own
   copy of the same four rules and the seventh, `summit`, wrote none: both pickers rendered as
   raw browser `<select>` controls — rounded, grey, system-font, native arrow — in the header of
   a theme built out of hairlines and a serif. A per-item rule is a thing you forget, and theme
   number eight would have forgotten it too.

   Deliberately *not* `appearance: none` here. Four of the six themes keep the native arrow and
   pad accordingly; suppressing it from this file would leave them with no arrow and no room for
   the replacement. A theme that wants the chevron draws it itself, as aurora and signal do.

   The `color` and `option` declarations matter beyond summit: atelier, bloom, campus and marquee
   all set `background: var(--color-surface)` on the select and no `color`, so the text was
   whatever the browser paints by default — near-black on a dark palette's dark surface. A select
   does not inherit `color` on its own, and the dropped-open list is painted by the OS, which
   knows nothing about the page's palette. */
.currency-switcher,
.language-switcher { position: relative; display: flex; align-items: center; gap: 0.4rem; }
/* `appearance: none` is the load-bearing line, and it was missing.
   Without it the *operating system* paints the widget — grey, rounded, system font, native
   arrow — whatever border, background and font the rules below ask for. So both pickers sat in
   the header of every theme looking like a macOS control on a page built out of hairlines and a
   serif, and `test_shared_controls_browser.py` reported them as styled the whole time: it asks
   whether the computed style *differs from a bare select*, and it does differ, in three
   properties that the OS then ignores. "Not identical to a default" is not "styled".
   `tests/themes/test_storefront_rendering_browser.py` asks the question that matters instead. */
.currency-select,
.locale-select {
  appearance: none;
  -webkit-appearance: none;
  padding: 0.4rem 1.85rem 0.4rem 0.6rem;
  border: 1px solid var(--c-line-strong);
  border-radius: var(--c-radius-sm);
  background: var(--c-bg);
  color: var(--c-text);
  font: inherit;
  font-size: 0.85rem;
  line-height: 1.2;
  cursor: pointer;
}
/* The arrow the OS is no longer drawing. On the wrapping form rather than the select, because a
   `<select>` cannot carry a pseudo-element — and drawn in `currentColor` rather than as a data
   URI, so it follows the palette into dark mode instead of being a colour literal in a
   background image. */
.currency-switcher::after,
.language-switcher::after {
  content: "";
  position: absolute;
  right: 0.7rem;
  top: 50%;
  width: 0.4rem;
  height: 0.4rem;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-70%) rotate(45deg);
  pointer-events: none;
  opacity: 0.7;
}
.currency-select:focus-visible,
.locale-select:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; }
.currency-select option,
.locale-select option { background: var(--c-bg); color: var(--c-text); }

/* The legal links row (`{% policy_links %}`), rendered by every theme's footer. It had no rule
   anywhere: unstyled, it sat outside the footer's own container and hard against the window
   edge. Nobody saw it because a new shop publishes no policies, and the cookie notice — which
   is always linked — is what made it visible. */
.footer-policies {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.25rem;
  /* Both sides: every theme rules a line along the top of `.footer-bottom`, which comes next,
     and with no bottom padding the links sat 2px above it on all twelve. */
  padding-block: 1rem;
  font-size: 0.9rem;
}

.footer-policies a { color: inherit; opacity: 0.75; }
.footer-policies a:hover { opacity: 1; }

/* Three more one-line rules that four of the seven themes happened to write and the rest did
   not, in shared markup none of them override: the footer's attribution line, the "12 products"
   count above a listing, and the sentence under a sign-up heading. Each rendered at full body
   size and full body ink on whichever themes had forgotten it. Baselines, so theme number eight
   inherits them; every theme that already has its own keeps it (all of theirs are scoped to
   `body.theme-<name>`, and this file is linked last). */
/* `--c-muted` is the *page's* muted ink, and three themes put this line in a footer painted
   with the ink colour — so it was page-grey on near-black: 1.96:1. It sits inside
   `.footer-bottom`, which every theme colours, so inheriting is both correct and the only
   value that follows a theme into an inverted footer. Found by axe-core, because the
   hand-rolled sweep was skipping the whole footer as 'deliberately dimmed'. */
.powered-by { margin-top: 0.35rem; font-size: 0.8rem; color: inherit; }
.listing-count { margin: 0; font-size: 0.9rem; color: var(--c-muted); }
.auth-sub { color: var(--c-muted); margin-bottom: 1.5rem; }

/* ---- Product page sections --------------------------------------------------------------

   The description, the course panel, the venue and the reviews — everything below the picture
   and the buy button. The templates (`partials/_product_sections.html`, `_course_panel.html`,
   `reviews/_reviews.html`) are shared by every theme and overridden by none, so their styling
   lives here rather than being re-written six times: one component, one owner, the same
   arrangement cart and checkout already have. Themes steer it through the `--c-*` tokens they
   already re-point, and may override with `body.theme-<name> …` (higher specificity) where the
   look demands it.

   Before this block none of these classes were styled anywhere at all: the reviews sat inside
   the buy column as unstyled headings, a browser-default `<ol>` and a bare form. */
.product-sections {
  margin-top: clamp(2.5rem, 6vw, 4.5rem);
  display: grid;
  gap: clamp(2rem, 5vw, 3.5rem);
}
/* Most themes lay the sections into their product grid, so a booking panel that runs long
   scrolls beside the description and the reviews rather than beside an empty column. There the
   grid's own gap is the space above them, not this margin as well. `body ` is load-bearing:
   several themes set that margin from `body.theme-<name> .product-sections` at (0,2,1). */
body .product-layout > .product-sections { margin-top: 0; min-width: 0; }
.product-section { scroll-margin-top: 5rem; }
.product-section + .product-section { padding-top: clamp(2rem, 5vw, 3.5rem); border-top: 1px solid var(--c-line); }
.product-section-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 0.5rem 1.5rem; flex-wrap: wrap; margin-bottom: 1.25rem;
}
.product-section-title { font-size: clamp(1.2rem, 2.4vw, 1.6rem); margin: 0; }
.product-section-body { max-width: 68ch; }
.product-description { line-height: 1.7; }
.product-description > * + * { margin-top: 0.9rem; }
.product-description h2, .product-description h3 { font-size: 1.05rem; margin-top: 1.5rem; }
.product-description ul, .product-description ol { padding-left: 1.25rem; }

/* The specification list: the facts a shopper checks rather than reads. A row hides itself when
   Alpine finds nothing to put in it (an unselected variant has no SKU), so this never prints a
   label with an empty value — which is exactly what the old stray "SKU" line did. */
.product-specs {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(min(11rem, 100%), max-content));
  gap: 0.75rem 2.5rem; margin: 1.75rem 0 0; padding-top: 1.25rem; border-top: 1px solid var(--c-line);
}
.product-spec dt { font-size: 0.75rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--c-muted); }
.product-spec dd { margin: 0.2rem 0 0; font-weight: 600; }

/* ---- Course panel ------------------------------------------------------------------------ */
.course-facts { color: var(--c-muted); font-size: 0.9rem; margin: 0; }
.course-outcomes { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.6rem; }
.course-outcomes li { display: flex; gap: 0.6rem; align-items: baseline; }
.course-outcomes li::before { content: "✓"; color: var(--c-accent); font-weight: 700; flex: none; }
body p.course-cta { margin: 1.5rem 0 0; }

/* ---- Reviews ------------------------------------------------------------------------------ */
.product-reviews { display: grid; gap: 0; }
.reviews-count { color: var(--c-muted); font-size: 0.9rem; margin: 0; }
.reviews-empty { margin: 0; }
.reviews-summary {
  display: flex; flex-wrap: wrap; align-items: center; gap: clamp(1.25rem, 4vw, 3rem);
  padding: 1.25rem 1.5rem; border: 1px solid var(--c-line); border-radius: var(--c-radius);
  background: var(--c-soft);
}
.reviews-score { display: flex; flex-direction: column; gap: 0.25rem; }
.reviews-average { font-size: 2.5rem; font-weight: 700; line-height: 1; margin: 0; font-variant-numeric: tabular-nums; }
.reviews-score .stars-count { display: none; }
.stars { letter-spacing: 0.1em; color: var(--c-accent); }
.star-empty { opacity: 0.3; }
.star-half { opacity: 0.6; }
.stars-count { color: var(--c-muted); font-size: 0.9rem; margin-left: 0.4rem; }

/* Capped, not `flex: 1` — a bar stretched across a 74rem page reads as a progress meter for the
   whole shop rather than a share of four reviews. */
.rating-breakdown { list-style: none; margin: 0; padding: 0; flex: 1 1 16rem; max-width: 26rem; display: grid; gap: 0.3rem; }
.rating-row { display: grid; grid-template-columns: 2.5rem 1fr 2rem; align-items: center; gap: 0.6rem; font-size: 0.85rem; }
.rating-row-score { color: var(--c-muted); white-space: nowrap; }
.rating-row-count { text-align: right; color: var(--c-muted); font-variant-numeric: tabular-nums; }
.rating-bar { display: block; height: 0.5rem; border-radius: 999px; background: var(--c-line); overflow: hidden; }
.rating-bar-fill { display: block; height: 100%; background: var(--c-accent); }

.review-list { list-style: none; margin: 1.75rem 0 0; padding: 0; display: grid; gap: 1.5rem; }
.review { padding-bottom: 1.5rem; border-bottom: 1px solid var(--c-line); }
.review:last-child { padding-bottom: 0; border-bottom: 0; }
.review-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; }
.review-author { display: flex; align-items: center; gap: 0.6rem; margin: 0; }
.review-rating { margin: 0; color: var(--c-accent); letter-spacing: 0.1em; }
body h3.review-title { font-size: 1rem; margin: 0.6rem 0 0; }
body p.review-body { margin: 0.4rem 0 0; line-height: 1.65; max-width: 68ch; }
.review-date { margin: 0.5rem 0 0; font-size: 0.8rem; color: var(--c-muted); }
.review-reply {
  margin-top: 0.9rem; padding: 0.9rem 1.1rem; border-radius: var(--c-radius-sm);
  background: var(--c-soft); border-left: 3px solid var(--c-accent); font-size: 0.925rem;
}
.review-reply-head { margin: 0 0 0.3rem; }
.review-reply p + p { margin-top: 0.4rem; }

.review-form { margin-top: 2rem; padding-top: 1.75rem; border-top: 1px solid var(--c-line); max-width: 34rem; }
body h3.review-form-title { font-size: 1.05rem; margin: 0 0 1rem; }
.rating-input { border: 0; padding: 0; margin: 0 0 1.25rem; }
.rating-input legend { padding: 0; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.5rem; }
.rating-input-values { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.rating-star { display: inline-flex; }
.rating-star input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.rating-star span {
  display: inline-flex; align-items: center; gap: 0.1rem; padding: 0.4rem 0.85rem;
  border: 1px solid var(--c-line-strong); border-radius: 999px; cursor: pointer; font-size: 0.9rem;
}
.rating-star input:checked + span { background: var(--c-accent); color: var(--c-bg); border-color: var(--c-accent); }
.rating-star input:focus-visible + span { outline: 2px solid var(--c-accent); outline-offset: 2px; }
/* Same separation as the form it stands in for — without it "Sign in to leave a review" reads
   as one more line of the last review. */
/* `body p.` because this is a `<p>`, and every theme's `body.theme-<name> p { margin: 0 }` is
   (0,1,2): the one-class rule lost, and so did `body .review-blocked` at (0,1,1). The "no reviews
   yet" line rested on this one's top border on all twelve themes. */
body p.review-blocked { margin: 2rem 0 0; padding-top: 1.75rem; border-top: 1px solid var(--c-line); }

/* ---- Learning: the shelf, the curriculum, the lesson --------------------------------------
   `storefront/learning/{my_courses,player,lesson}.html` are shared templates that no theme
   overrides, so — like the product sections and the review furniture above — commerce.css owns
   their styling for all six themes and a theme tweaks them only from `body.theme-<name>`.

   They were written against the *admin* design system: `.card`, `.badge`, `.empty` and
   `.progress` are all defined in `static/css/dashboard.css`, which a storefront page never
   loads, and `.progress-bar` existed in no stylesheet in the project at all. So every course
   page rendered as browser defaults — an unstyled `<ol>` for the curriculum, a bare `<span>`
   where the progress meter should be. The templates now use the storefront's own vocabulary
   (`.order-panel`, `.pill`, `.empty-state`, `.course-outcomes`) and everything genuinely new to
   a course page is defined below. */

/* The meter. A `<span>` with a width, so it needs `display: block` to have a height at all —
   which is why the old markup showed nothing whatsoever rather than merely showing it plain. */
.progress {
  display: block; height: 0.5rem; margin: 0.75rem 0 0.4rem; overflow: hidden;
  border-radius: 999px; background: var(--c-line);
}
.progress-bar { display: block; height: 100%; background: var(--c-accent); border-radius: inherit; }

/* "My learning". Cards, not rows: the shelf is the first thing a learner sees after paying. */
.course-shelf {
  list-style: none; margin: 0; padding: 0; display: grid; gap: 1.25rem;
  grid-template-columns: repeat(auto-fill, minmax(min(22rem, 100%), 1fr));
}
.course-card {
  display: flex; flex-direction: column; gap: 0.35rem; padding: 1.5rem;
  border: 1px solid var(--c-line); border-radius: var(--c-radius); background: var(--c-bg);
}
.course-card h2 { font-size: 1.15rem; margin: 0; }
.course-card h2 a { text-decoration: none; color: inherit; }
.course-card h2 a:hover { color: var(--c-accent); }
.course-card p { margin: 0; }
/* Pushed to the foot so buttons line up across cards whose blurbs differ in length. */
.course-card .btn { margin-top: auto; align-self: flex-start; }

/* "My bookings". An appointment is one line of fact, so it is a stacked list rather than the
   card grid the shelf uses — but it is styled here, by the stylesheet the storefront loads,
   for the same reason the course pages are. */
.booking-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1rem; }
.booking-list h2 { font-size: 1.05rem; margin: 0 0 0.35rem; }
.booking-list h2 a { text-decoration: none; color: inherit; }
.booking-list h2 a:hover { color: var(--c-accent); }
.booking-list p { margin: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }

/* The course page's head: what it is, what it is about, the facts, then one box to act from.
   The headings and paragraphs here are written `body p.x` / `body .x h1` on purpose: every theme
   resets `p` and `h1`–`h4` to `margin: 0` at (0,1,2), which beats a one-class rule. That is how
   the title, subtitle, facts and first module used to sit in one undivided stack. */
.course-head {
  display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.5rem 3rem; align-items: start;
  margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--c-line);
}
@media (min-width: 760px) {
  .course-head { grid-template-columns: minmax(0, 1fr) minmax(15rem, 18rem); }
}
body p.course-eyebrow {
  margin: 0 0 0.65rem; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--c-muted);
}
body .course-head h1 { margin: 0; font-size: clamp(2rem, 4.5vw, 3rem); }
body p.course-lead { margin: 0.85rem 0 0; max-width: 40rem; font-size: clamp(1.1rem, 2vw, 1.3rem); color: var(--c-muted); }
.course-meta { list-style: none; margin: 1.35rem 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
.course-meta li { padding: 0.3rem 0.8rem; border: 1px solid var(--c-line); border-radius: 999px; font-size: 0.85rem; }
/* One place to act from: the price and Enrol for a visitor, progress and the next lesson for a
   learner. A grid with a gap, so nothing in it depends on a margin a theme resets. */
.course-action {
  display: grid; gap: 0.75rem; padding: 1.25rem;
  border: 1px solid var(--c-line); border-radius: var(--c-radius);
}
body .course-action p { margin: 0; }
body p.course-price { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
.course-action .progress { margin: 0.25rem 0 0; }
.course-try { justify-self: center; font-size: 0.9rem; }

/* The curriculum. A module is a part of a syllabus, so it gets air above it rather than a box
   inside the box it already sits in. */
body p.course-note {
  margin: 0 0 1.75rem; padding: 0.75rem 1rem; font-size: 0.925rem; color: var(--c-text);
  background: var(--c-soft); border-left: 3px solid var(--c-accent); border-radius: var(--c-radius-sm);
}
.module + .module { margin-top: 1.75rem; padding-top: 1.75rem; border-top: 1px solid var(--c-line); }
.module-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 0.25rem 1rem; }
body .module h3 { font-size: 1.15rem; margin: 0; }
body p.module-summary { margin: 0.35rem 0 0; font-size: 0.925rem; color: var(--c-muted); }
/* `.pill` capitalises because it usually holds a status word; here it holds a phrase, and
   "Not On Your Route" reads as a shout. */
body .lesson-row .pill { text-transform: none; }

.lesson-list { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.15rem; }
.lesson-row {
  display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem 0.75rem;
  padding: 0.7rem 0.85rem; border-radius: var(--c-radius-sm);
}
.lesson-row:hover { background: var(--c-soft); }
.lesson-row > a { font-weight: 600; text-decoration: none; }
.lesson-row > a:hover { text-decoration: underline; }
/* The lesson kind ("Video", "Quiz") is the last thing on the row and the least important:
   pushed right so the titles stay a readable column down the left. */
.lesson-row > .c-small:last-child { margin-left: auto; color: var(--c-muted); }
/* A locked lesson is still listed — knowing what is coming is most of why the page exists —
   but it must not look clickable. */
.lesson-locked { color: var(--c-muted); }
.lesson-row:has(.lesson-locked):hover { background: none; }

/* The lesson itself. A measure, because this is the one storefront page that is genuinely read
   rather than scanned. */
.lesson-body { line-height: 1.7; }
.lesson-body > * + * { margin-top: 1rem; }
.lesson-body p, .lesson-body ul, .lesson-body ol { max-width: 68ch; }
.lesson-body ul, .lesson-body ol { padding-left: 1.25rem; }
.lesson-body h2, .lesson-body h3 { font-size: 1.1rem; margin-top: 1.75rem; }
.lesson-body img { border-radius: var(--c-radius-sm); }

.video-embed { position: relative; aspect-ratio: 16 / 9; border-radius: var(--c-radius-sm); overflow: hidden; background: var(--c-soft); }
.video-embed iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
/* "Still being prepared" is a status, not body copy — it should not read as the lesson. */
.video-status {
  margin: 0 0 1rem; padding: 0.75rem 1rem; border-radius: var(--c-radius-sm);
  background: var(--c-soft); color: var(--c-muted); font-size: 0.925rem;
}

.quiz { margin-top: 1.5rem; }
.quiz h2 { font-size: 1.15rem; margin: 0 0 0.4rem; }
.quiz .btn { margin-top: 1.5rem; }
.quiz-question { border: 0; margin: 1.5rem 0 0; padding: 0; }
.quiz-question legend { padding: 0; margin-bottom: 0.6rem; font-weight: 600; }
.quiz-question + .quiz-question { padding-top: 1.5rem; border-top: 1px solid var(--c-line); }

/* The author's quiz view: answers marked, nothing to submit. `.quiz-question` above is a
   fieldset for the learner and a plain div here, so the prompt needs its own weight. */
.quiz-prompt { font-weight: 600; margin: 0 0 0.6rem; }
.quiz-answers { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.3rem; }
.quiz-answers li { color: var(--c-muted); }
.quiz-answers li.is-correct { color: var(--c-text); font-weight: 600; }
.quiz-answers li.is-correct::before { content: "✓"; color: var(--c-accent); margin-right: 0.4rem; }

/* The marked attempt, worked through. The signal is carried by a left border and the pill, not
   by colouring the text: `--c-ok` on `--c-soft` is the pairing that measures 2.74:1–3.85:1
   across the palettes, and a learner reading why they got something wrong is reading body copy. */
.quiz-review { margin-top: 1.75rem; padding-top: 1.5rem; border-top: 1px solid var(--c-line); }
.quiz-review h3 { font-size: 1rem; margin: 0 0 1rem; }
.quiz-review-row { padding: 0.85rem 1rem; border-left: 3px solid var(--c-line); background: var(--c-soft); border-radius: var(--c-radius-sm); }
.quiz-review-row + .quiz-review-row { margin-top: 0.75rem; }
.quiz-review-row.is-correct { border-left-color: var(--c-ok); }
.quiz-review-row.is-wrong { border-left-color: var(--c-danger); }
.quiz-review-row .quiz-prompt { display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap; }
.quiz-answers li.is-picked { color: var(--c-text); }
.quiz-explanation { margin: 0.6rem 0 0; font-size: 0.925rem; }

/* Starting again. Set apart from the curriculum below it, because it throws work away and a
   button that does that should not sit flush against the list of lessons. */
.retake-form { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin: 1.5rem 0; }

/* Work a person marks. The state a learner most needs is "where has my work got to", so each
   attempt is a block carrying its own status and the marker's words. */
.assignment { margin-top: 1.5rem; }
.assignment h2 { font-size: 1.15rem; margin: 0 0 0.75rem; }
.assignment form { margin-top: 1.25rem; }
.submission { padding: 0.85rem 1rem; margin-top: 0.75rem; border: 1px solid var(--c-line); border-radius: var(--c-radius-sm); background: var(--c-soft); }
.submission-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin: 0; }
.submission-text { margin-top: 0.6rem; }
.submission-text > *:last-child { margin-bottom: 0; }
/* The feedback is the part they came back for, so it is set apart from their own words. */
.submission-feedback { margin: 0.75rem 0 0; padding-left: 0.85rem; border-left: 3px solid var(--c-accent); }
.submission-feedback > *:last-child { margin-bottom: 0; }

/* The clock. Reported, not kept — `QuizAttempt.started_at` is the authority — so this is a
   status line rather than a control, and it carries its signal in a border for the reason
   `.alert` does: `--c-warn` on `--c-warn-bg` measures 2.74:1–3.85:1 across the palettes. */
.quiz-clock {
  margin: 0.75rem 0 0; padding: 0.6rem 0.85rem; font-size: 0.925rem;
  border: 1px solid var(--c-line); border-left: 3px solid var(--c-warn);
  border-radius: var(--c-radius-sm); background: var(--c-soft); color: var(--c-text);
}

/* The four question kinds a list of radio buttons cannot ask. All plain form controls on
   purpose: drag-and-drop needs JavaScript to be answerable at all, and a quiz that cannot be sat
   without it is a quiz some learners cannot sit. */
.blank-list, .sort-list, .match-list { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.6rem; }
.blank-list .field { margin: 0; }
.sort-list li, .match-list li { display: flex; align-items: center; gap: 0.75rem; }
.match-list li > span:first-child { flex: 1 1 45%; }
.match-list select { flex: 1 1 45%; }
.input-narrow { width: 5rem; flex: none; }

/* What they wrote beside what was wanted. Two columns where there is room, because comparing
   them is the whole point and a wrong answer stacked above the right one reads as a list. */
.quiz-given { display: grid; gap: 1rem; margin-top: 0.5rem; }
@media (min-width: 40rem) { .quiz-given:has(> * + *) { grid-template-columns: 1fr 1fr; } }
.quiz-given p { margin: 0 0 0.3rem; }

/* A fork. Full-width buttons stacked, because these are the sentences the learner is choosing
   between and a row of them would be read as a toolbar. */
.lesson-choices { margin-top: 1.5rem; }
.lesson-choices h2 { font-size: 1.15rem; margin: 0 0 0.75rem; }
.lesson-choice-list { list-style: none; margin: 1rem 0 0; padding: 0; display: grid; gap: 0.6rem; }
.lesson-choice-list form { margin: 0; }
/* A choice offered to somebody who cannot take it (not enrolled, or an arm that ends the
   course while previewing) is still worth showing — it is what they are buying. */
.lesson-choice-list .is-disabled { opacity: 0.6; cursor: default; text-align: center; }

/* Where a quiz leads, shown to an author previewing. */
.lesson-routes { margin-top: 1.5rem; }
.lesson-routes h2 { font-size: 1.15rem; margin: 0 0 0.75rem; }
.lesson-route-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
.lesson-route-list li { display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem; }

body p.lesson-ending { margin-top: 1.5rem; }
/* A lesson the learner's answers have routed them past. Listed, because knowing the course has
   more in it is most of why the curriculum page exists, but plainly not theirs to work through. */
.lesson-off-route { opacity: 0.65; }

/* Staff previewing a course. A band rather than the fixed bar the theme preview uses: this one
   belongs to a page, not to the whole shop, and a course page is read top to bottom. */
.course-preview {
  display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
  gap: 0.75rem; margin-bottom: 1.5rem; padding: 0.75rem 1rem;
  border: 1px solid var(--c-line); border-left: 3px solid var(--c-accent);
  border-radius: var(--c-radius-sm); background: var(--c-soft); color: var(--c-text);
  font-size: 0.925rem;
}
.course-preview-note { margin: 0; }
.course-preview-stop { font-weight: 600; }

.lesson-actions { margin-top: 1.5rem; }
/* `justify-content: space-between` alone would centre a lone "next" link; the margin keeps
   "next" on the right when there is no "previous" beside it. */
.lesson-nav {
  display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem;
  margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--c-line);
}
.lesson-nav a { font-weight: 600; text-decoration: none; }
.lesson-nav a:hover { text-decoration: underline; }
.lesson-nav a:only-child:last-child { margin-left: auto; }

@media (max-width: 640px) {
  .reviews-summary { gap: 1rem; padding: 1rem; }
  .product-specs { gap: 0.75rem 1.5rem; }
  .course-card { padding: 1.25rem; }
  .lesson-row { padding: 0.6rem 0.5rem; }
  .lesson-row > .c-small:last-child { margin-left: 0; flex-basis: 100%; }
}

/* ------------------------------------------------------------------ tours & experiences

   The experience partials (`apps/events/templates/events/_experience_*.html`,
   `_booking_panel.html` and `experience_list.html`) are
   shared markup that **no theme overrides**, so they are styled here for all seven — the same
   arrangement the product sections, the review furniture and the course pages arrived at, and
   for the same reason: a component with no owner is a component nobody styles.

   Everything below is written against the `--c-*` tokens, which each theme re-points from
   `body.theme-<name>`. No literal colour appears here except the grade bars' "off" state, which
   is a mix of the ink rather than a hex. A theme tweaks these from `body.theme-<name> …` —
   this file is linked *after* the theme stylesheet, so a bare class of equal specificity in a
   theme is a rule that exists and never applies. */

/* The fact band: duration, grade, group size, languages, ages. Printed as data because that is
   what somebody deciding whether they can do this actually reads. */
.evt-guide-facts {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(min(7.5rem, 100%), 1fr));
  gap: 0.25rem 1.25rem; margin: 0 0 1.25rem; padding: 0.9rem 0;
  border-top: 1px solid var(--c-line); border-bottom: 1px solid var(--c-line);
}
.evt-guide-fact { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
.evt-guide-fact dt {
  font-size: 0.7rem; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--c-muted); font-weight: 600;
}
.evt-guide-fact dd { margin: 0; font-weight: 600; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
.evt-guide-grade { display: inline-flex; gap: 2px; align-items: flex-end; }
.evt-guide-grade-bar {
  display: block; width: 4px; height: 8px; border-radius: 1px;
  background: color-mix(in srgb, var(--c-text) 18%, transparent);
}
.evt-guide-grade-bar:nth-child(2) { height: 11px; }
.evt-guide-grade-bar:nth-child(3) { height: 14px; }
.evt-guide-grade-bar:nth-child(4) { height: 17px; }
.evt-guide-grade-bar.is-on { background: var(--c-accent); }
.evt-guide-grade-label { font-weight: 600; }
.evt-guide-grade-note { margin: -0.75rem 0 1.25rem; color: var(--c-muted); font-size: 0.9rem; }

/* The booking panel. It replaces the theme's quantity box, so it has to read as the buy
   control on every theme rather than as another content block. */
.evt-book {
  border: 1px solid var(--c-line-strong); border-radius: var(--c-radius);
  background: var(--c-soft); padding: 1.25rem; display: grid; gap: 1rem;
}
.evt-book-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.evt-book-title { margin: 0; font-size: 1.1rem; }
.evt-book-from { margin: 0; display: flex; align-items: baseline; gap: 0.35rem; }
.evt-book-from-label { color: var(--c-muted); font-size: 0.85rem; }
.evt-book-from-price { font-size: 1.35rem; font-weight: 700; }
.evt-book-form { display: grid; gap: 1rem; margin: 0; }
.evt-book-field { display: grid; gap: 0.35rem; }
/* A `<legend>` is not a grid item of its fieldset, so the field's `gap` never reached it: the
   "Who is coming?" label sat on the first ticket row's rule. */
.evt-book-field-label { font-weight: 600; font-size: 0.9rem; padding: 0; margin-bottom: 0.5rem; }
.evt-book-select {
  width: 100%; padding: 0.6rem 0.7rem; border-radius: var(--c-radius-sm);
  border: 1px solid var(--c-line-strong); background: var(--c-bg); color: var(--c-text);
  font: inherit;
}
.evt-book-pax, .evt-book-addons { border: 0; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
.evt-book-pax-row {
  display: grid; grid-template-columns: 1fr auto 4.5rem; align-items: center; gap: 0.75rem;
  padding: 0.5rem 0; border-top: 1px solid var(--c-line);
}
.evt-book-pax-who { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
.evt-book-pax-name { font-weight: 600; }
.evt-book-pax-age, .evt-book-pax-note { font-size: 0.82rem; color: var(--c-muted); }
.evt-book-pax-price { font-weight: 600; white-space: nowrap; }
.evt-book-pax-qty {
  width: 100%; padding: 0.45rem 0.5rem; text-align: center; font: inherit;
  border-radius: var(--c-radius-sm); border: 1px solid var(--c-line-strong);
  background: var(--c-bg); color: var(--c-text);
}
.evt-book-addon-row .evt-book-pax-name { font-weight: 500; }
.evt-book-total {
  display: flex; align-items: baseline; justify-content: space-between;
  padding-top: 0.75rem; border-top: 2px solid var(--c-line-strong); font-weight: 700;
}
.evt-book-total-value { font-size: 1.25rem; }
.evt-book-btn { width: 100%; }
.evt-book-terms { margin: 0; font-size: 0.8rem; color: var(--c-muted); }
.evt-book-enquiry, .evt-book-empty { display: grid; gap: 0.75rem; }
.evt-book-enquiry p, .evt-book-empty p { margin: 0; }
.evt-book-enquiry-btn { justify-self: start; }
.evt-book-private { margin: 0; font-size: 0.85rem; color: var(--c-muted); }

/* The content sections. They sit inside `.product-sections`, so they inherit its rhythm and
   only declare what is genuinely theirs. */
.evt-guide-section .product-section-title { margin-top: 0; }
/* A field-guide section ends in a bare list, and the next section opens with its rule: without
   this the last highlight sat 3px above that line. */
.evt-guide-section { padding-bottom: 1.5rem; }
.evt-guide-highlights, .evt-guide-bring { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.5rem; }
.evt-guide-highlights li, .evt-guide-bring li { position: relative; padding-left: 1.4rem; }
.evt-guide-highlights li::before, .evt-guide-bring li::before {
  content: ""; position: absolute; left: 0; top: 0.55em;
  width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--c-accent);
}
.evt-guide-itinerary { margin: 0; padding: 0; list-style: none; display: grid; gap: 0; }
.evt-guide-stop { position: relative; padding: 0 0 1.25rem 1.75rem; border-left: 2px solid var(--c-line); }
.evt-guide-stop:last-child { border-left-color: transparent; padding-bottom: 0; }
.evt-guide-stop::before {
  content: ""; position: absolute; left: -0.4rem; top: 0.35rem;
  width: 0.7rem; height: 0.7rem; border-radius: 50%;
  background: var(--c-bg); border: 2px solid var(--c-accent);
}
.evt-guide-stop-day {
  margin: 0 0 0.5rem; font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--c-muted); font-weight: 700;
}
.evt-guide-stop-body { display: grid; gap: 0.25rem; }
.evt-guide-stop-time { font-size: 0.8rem; font-weight: 700; color: var(--c-accent); }
.evt-guide-stop-title { margin: 0; font-size: 1rem; }
.evt-guide-stop-text { margin: 0; color: var(--c-muted); }
.evt-guide-includes { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 1.5rem; }
/* `body h3.`: "Included" sat directly on its first line on voyage, whose `h3 { margin: 0 }` is
   (0,1,2) and beat this rule's one class. */
body h3.evt-guide-include-title { margin: 0 0 0.5rem; font-size: 0.95rem; }
.evt-guide-include-col ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.4rem; }
.evt-guide-include-col li { position: relative; padding-left: 1.5rem; }
.evt-guide-include-yes li::before {
  content: "\2713"; position: absolute; left: 0; color: var(--c-ok); font-weight: 700;
}
.evt-guide-include-no li::before {
  content: "\2715"; position: absolute; left: 0; color: var(--c-danger); font-weight: 700;
}
.evt-guide-include-no li { color: var(--c-muted); }
/* `body p`: paragraphs, so the theme reset beat the bare classes and the meeting time sat on the
   venue box under it. */
body p:is(.evt-guide-fitness, .evt-guide-meeting, .evt-guide-meeting-time, .evt-guide-pickup) { margin: 0 0 0.75rem; }
.evt-guide-meeting-time, .evt-guide-pickup { color: var(--c-muted); font-size: 0.9rem; }
.evt-guide-faqs { display: grid; gap: 0.5rem; }
.evt-guide-faq { border: 1px solid var(--c-line); border-radius: var(--c-radius-sm); background: var(--c-bg); }
.evt-guide-faq-q { padding: 0.75rem 1rem; cursor: pointer; font-weight: 600; list-style: none; }
.evt-guide-faq-q::-webkit-details-marker { display: none; }
.evt-guide-faq-q::after { content: "+"; float: right; color: var(--c-muted); font-weight: 400; }
.evt-guide-faq[open] .evt-guide-faq-q::after { content: "\2013"; }
.evt-guide-faq-a { padding: 0 1rem 0.9rem; color: var(--c-muted); }
.evt-guide-faq-a > :first-child { margin-top: 0; }

/* `/experiences/` — the index. */
.evt-list { padding: 2rem 0 3rem; }
.evt-list-head { margin-bottom: 1.5rem; }
body .evt-list-head h1 { margin: 0 0 0.4rem; }
.evt-list-lead { margin: 0; color: var(--c-muted); max-width: 46ch; }
.evt-list-filters {
  display: flex; flex-wrap: wrap; gap: 0.75rem 1rem; align-items: end;
  padding: 1rem; margin-bottom: 1.5rem;
  border: 1px solid var(--c-line); border-radius: var(--c-radius); background: var(--c-soft);
}
.evt-list-filter { display: grid; gap: 0.3rem; }
.evt-list-filter label { font-size: 0.78rem; font-weight: 600; color: var(--c-muted); }
.evt-list-filter select {
  padding: 0.5rem 0.6rem; border-radius: var(--c-radius-sm); font: inherit;
  border: 1px solid var(--c-line-strong); background: var(--c-bg); color: var(--c-text);
}
.evt-list-filter-clear { align-self: center; color: var(--c-muted); font-size: 0.9rem; }
.evt-list-grid {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fill, minmax(min(17rem, 100%), 1fr)); gap: 1.5rem;
}
.evt-list-card {
  border: 1px solid var(--c-line); border-radius: var(--c-radius); overflow: hidden;
  background: var(--c-bg); display: flex;
}
.evt-list-card-link { display: flex; flex-direction: column; width: 100%; text-decoration: none; color: inherit; }
.evt-list-card-media { position: relative; aspect-ratio: 4 / 3; background: var(--c-soft); overflow: hidden; }
.evt-list-card-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.evt-list-card-media-empty { display: block; width: 100%; height: 100%; }
.evt-list-card-flag {
  position: absolute; top: 0.6rem; left: 0.6rem; padding: 0.2rem 0.5rem;
  border-radius: 999px; background: var(--c-danger); color: var(--c-bg);
  font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
.evt-list-card-body { padding: 1rem; display: grid; gap: 0.5rem; flex: 1; }
.evt-list-card-title { margin: 0; font-size: 1.05rem; }
.evt-list-card-summary { margin: 0; color: var(--c-muted); font-size: 0.9rem; }
.evt-list-card-facts { margin: 0; display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; }
.evt-list-card-facts div { display: flex; flex-direction: column; }
.evt-list-card-facts dt {
  font-size: 0.65rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--c-muted);
}
.evt-list-card-facts dd { margin: 0; font-size: 0.85rem; font-weight: 600; }
.evt-list-card-foot {
  display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem;
  padding: 0.85rem 1rem; border-top: 1px solid var(--c-line);
}
.evt-list-card-price { font-weight: 700; }
.evt-list-card-from { font-size: 0.75rem; font-weight: 500; color: var(--c-muted); }
.evt-list-card-cta { font-size: 0.85rem; font-weight: 600; color: var(--c-accent); }

/* ------------------------------------------------------------------------------------------
   The date search (`events/_date_search.html`) and `/whats-on/`.

   One control in two places — a panel a theme floats over its hero, and the form above the
   results — so it is styled here for all seven and a theme tweaks it from `body.theme-<name>`,
   the arrangement `.site-search` has. The date and number inputs are the browser's own
   controls, so `color-scheme` on the field is what turns the picker's own chrome dark: without
   it the calendar popup is a white sheet under a dark page. */
.date-search {
  display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end;
  padding: 1rem; border: 1px solid var(--c-line); border-radius: var(--c-radius);
  background: var(--c-bg);
}
/* `minmax(0, 1fr)`, not the implicit `auto` track: a date input's minimum is its own content
   (the placeholder and the calendar button), so on a phone the "To" box grew its column and ran
   past the panel's edge — `min-width: 0` on the field alone does not reach the track inside it. */
.date-search-field { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.3rem; flex: 1 1 8rem; min-width: 0; }
.date-search-field-wide { flex: 2 1 12rem; }
.date-search-field-narrow { flex: 0 1 6rem; }
.date-search-field label { font-size: 0.78rem; font-weight: 600; color: var(--c-muted); }
.date-search-field input {
  width: 100%; padding: 0.55rem 0.65rem; font: inherit;
  border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
  background: var(--c-bg); color: var(--c-text);
}
.date-search-field input:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; }
.date-search-submit { flex: 0 0 auto; }
/* The results page's own copy is the same form in a plainer frame: it sits under a heading
   that has already said what the page is, so a second card around it is a box in a box. */
body .date-search.date-search-page { border: 0; padding: 0; background: none; }

/* `/whats-on/` — dates, grouped by day. */
/* Every theme resets `h1`–`h4` and `p` to `margin: 0` at (0,1,2), so a one-class margin here is
   a rule that is in the file and never applies: each date heading sat directly on its first
   booking box, and the "57 dates" count on the search form above it. The day's spacing is a
   `gap`, which no margin reset can reach; the rest name **the tag and the class under `body`**
   — (0,1,2), which ties the theme's reset and wins by being linked later. `body .evt-when-count`
   is only (0,1,1) and still loses, which is exactly what the first attempt at this did.
   `geometry.py`'s `stacked-text` is the guard. */
.evt-when { padding: 2rem 0 3rem; }
body .evt-when-head { margin-bottom: 1.5rem; }
body .evt-when-head h1 { margin: 0 0 0.4rem; }
body p.evt-when-lead { margin: 0; color: var(--c-muted); max-width: 46ch; }
body p.evt-when-count { margin: 1.5rem 0 0.5rem; }
.evt-when-day { margin-top: 1.5rem; display: grid; gap: 0.65rem; }
body h2.evt-when-date {
  margin: 0; font-size: 0.95rem; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--c-muted);
}
.evt-when-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
.evt-when-item {
  display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; align-items: baseline;
  padding: 0.75rem 1rem; border: 1px solid var(--c-line); border-radius: var(--c-radius);
  background: var(--c-bg);
}
.evt-when-time { font-weight: 700; font-variant-numeric: tabular-nums; }
.evt-when-body { display: flex; flex-wrap: wrap; gap: 0.2rem 0.75rem; align-items: baseline; flex: 1; }
/* Every theme resets anchors to `color: inherit` at zero specificity, so a link in a list of
   dates has nothing marking it as one. The underline is on hover and focus rather than at rest,
   the way the rest of the storefront's in-text links behave. */
.evt-when-title { font-weight: 600; }
.evt-when-title:hover, .evt-when-title:focus-visible { text-decoration: underline; }
.evt-when-where { font-size: 0.85rem; }
.evt-when-state { font-size: 0.85rem; }

/* ------------------------------------------------------------------------------------------
   The slot picker (`scheduling/_picker.html`) — where a shopper buys a *time*.

   It replaces the quantity box on a service product's page, on all seven themes, and it was
   styled by **nothing**: `.slot-picker`, `.slot-day`, `.slot-times`, `.slot-time` and
   `.slot-paging` appeared in no stylesheet in the project, so every available time rendered as
   a browser-default radio button in a run-on paragraph. The same shape as the course pages and
   the contact form before them, and it survived for the same reason plus one more:
   `tests/themes/test_shared_template_classes.py` sweeps `themes/base/templates/storefront/`,
   and these templates live one directory over, in `themes/base/templates/scheduling/`.
   `tests/scheduling/test_picker_styles.py` is the sweep that covers them.

   A theme tweaks from `body.theme-<name>`: commerce.css is linked after the theme stylesheet,
   so a bare class of equal specificity is a rule that never applies. */
.slot-picker { display: grid; gap: 1rem; }
.slot-lead { margin: 0; }
.slot-staff { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
.slot-form { display: grid; gap: 1.25rem; margin: 0; }
/* One day open at a time. A fortnight of half-hour slots laid out in full was a column four to
   nine thousand pixels long on a service's product page, beside a photograph that ended on the
   first screen. Each day is a `<details>` sharing one `name`, so opening a day closes the last
   with no script — and a browser that predates exclusive `<details>` just lets several stay
   open, which is the old page rather than a broken one. */
.slot-days { display: grid; border-top: 1px solid var(--c-line); }
.slot-day { border-bottom: 1px solid var(--c-line); }
.slot-day-title {
  display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
  padding: 0.7rem 0.15rem; cursor: pointer; list-style: none;
  font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--c-text);
}
.slot-day-title::-webkit-details-marker { display: none; }
.slot-day-title::after {
  content: ""; flex: none; width: 0.45rem; height: 0.45rem; margin-right: 0.25rem;
  border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
  transform: translateY(-25%) rotate(45deg);
}
.slot-day[open] > .slot-day-title::after { transform: translateY(25%) rotate(-135deg); }
/* A time chosen on a day that has since been folded away is still the one the form will hold,
   so the day says so. */
.slot-day:has(input:checked) > .slot-day-title { box-shadow: inset 3px 0 0 var(--c-accent); padding-left: 0.65rem; }
.slot-day > .slot-times { padding-bottom: 0.9rem; }
.slot-times { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(6.5rem, 100%), 1fr)); gap: 0.4rem; }
/* Each time is a radio wearing a button. The input stays in the accessibility tree and keeps
   its keyboard behaviour; only its default appearance goes. */
.slot-time {
  position: relative; display: flex; flex-direction: column; align-items: center; gap: 0.1rem;
  padding: 0.5rem 0.4rem; border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
  background: var(--c-bg); color: var(--c-text); font-weight: 600; line-height: 1.2;
  cursor: pointer; text-align: center;
}
.slot-time input { position: absolute; inset: 0; opacity: 0; cursor: pointer; margin: 0; }
.slot-time:hover { border-color: var(--c-accent); }
.slot-time:focus-within { outline: 2px solid var(--c-accent); outline-offset: 2px; }
/* Selected carries its signal in the border and a tinted ground, keeping `--c-text` as the
   ink — the arrangement `.alert` uses. A filled `--c-primary` would have been the obvious
   choice and the wrong one: bloom and campus point that token at their accent rather than at
   their ink, which is exactly how the pay screens' home-made button measured 1.69:1. */
.slot-time:has(input:checked) {
  background: var(--c-accent-soft); border-color: var(--c-accent);
  box-shadow: inset 0 0 0 1px var(--c-accent);
}
.slot-paging { display: flex; gap: 0.5rem; }
.slot-picker .empty-state { padding: 2rem 1rem; }

/* The delivery window picker (`storefront/delivery/checkout_step.html`) is the same control
   one step later in the shop, and it was unstyled too — `.slot-grid` and `.slot-option` are on
   the debt list in `tests/themes/test_shared_template_classes.py`. One option is one option, so
   it shares the look above rather than growing a second vocabulary; the two lines inside it
   were renamed off `.slot-day` / `.slot-time`, which mean something else in the picker. */
.slot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(11rem, 100%), 1fr)); gap: 0.5rem; }
.slot-option {
  position: relative; display: flex; flex-direction: column; gap: 0.15rem;
  padding: 0.7rem 0.75rem; border: 1px solid var(--c-line-strong);
  border-radius: var(--c-radius-sm); background: var(--c-bg); color: var(--c-text);
  cursor: pointer; line-height: 1.3;
}
.slot-option input { position: absolute; inset: 0; opacity: 0; cursor: pointer; margin: 0; }
.slot-option:hover { border-color: var(--c-accent); }
.slot-option:focus-within { outline: 2px solid var(--c-accent); outline-offset: 2px; }
.slot-option:has(input:checked) {
  background: var(--c-accent-soft); border-color: var(--c-accent);
  box-shadow: inset 0 0 0 1px var(--c-accent);
}
.slot-option-day { font-weight: 600; }
.slot-option-time { font-variant-numeric: tabular-nums; color: var(--c-muted); }
.slot-option:has(input:checked) .slot-option-time { color: inherit; }

/* The customer's own booking page (`scheduling/booking.html`). */
/* `body p.` for the reason `.order-panel h2` above needs `body `: these are paragraphs, and a
   theme's `p { margin: 0 }` is (0,1,2). */
body p.booking-when { font-size: 1.15rem; margin: 0.75rem 0; }
.booking-instructions { margin: 0 0 0.75rem; color: var(--c-muted); }
.booking-instructions p:last-child { margin-bottom: 0; }
body p.booking-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 1rem 0 0; }
/* The "Need to change it?" card, for the same reason and with the same `body ` prefix: a theme
   resets `h2` and `p` to `margin: 0` at (0,1,2), so the heading sat directly on the sentence
   under it and that sentence sat 3px off the Cancel form — both under `geometry.py`'s 6px
   `stacked-text` bar, on the one card a customer reads before calling off an appointment. It
   renders only while `can_cancel`, so which booking the sweep happened to sample decided whether
   anything saw it. */
body h2.booking-change-title { margin: 0 0 0.5rem; }
body p.booking-change-note { margin: 0 0 1rem; }

/* The hire picker (`rentals/_picker.html`) and the customer's own hire page (`rentals/hire.html`).
   Shared markup no theme overrides, so this file owns it for all seven, as it owns the slot picker
   above; a theme tweaks from `body.theme-<name>`. `.rent-` rather than borrowing `.slot-`: a hire
   is not a slot, and one class name over two components in the one stylesheet every theme loads
   is the collision the delivery window picker had to be renamed out of. The error line is the
   shared `.alert`, whose signal is a border rather than a status colour pair. */
.rent-picker { display: grid; gap: 1rem; }
.rent-choose { display: grid; gap: 0.75rem; margin: 0; }
/* 11rem, not 9: a date input will not draw narrower than about 10rem, so at 9 a phone got two
   columns and the date ran across the gap onto the time beside it. Where two do not fit, the
   fields stack, as they already did in a desktop buy panel. `min-width: 0` is the second half: a
   grid item's minimum is its content, so without it any control can still spill out of its track. */
.rent-dates { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(11rem, 100%), 1fr)); gap: 0.75rem; }
.rent-dates .field, .rent-dates .input { min-width: 0; }
.rent-choose .field { margin: 0; }
.rent-quote {
  display: grid; gap: 0.6rem; padding: 1rem; border: 1px solid var(--c-line-strong);
  border-radius: var(--c-radius-sm); background: var(--c-soft); color: var(--c-text);
}
.rent-quote form { margin: 0; }
.rent-when { margin: 0; font-weight: 600; }
.rent-sums { display: grid; gap: 0.25rem; margin: 0; }
.rent-sums div { display: flex; justify-content: space-between; gap: 1rem; }
.rent-sums dt { color: var(--c-muted); }
.rent-sums dd { margin: 0; font-variant-numeric: tabular-nums; font-weight: 600; }
.rent-overview summary { cursor: pointer; font-weight: 600; }
.rent-days {
  list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(6.5rem, 100%), 1fr)); gap: 0.4rem;
}
.rent-day {
  display: flex; flex-direction: column; gap: 0.1rem; padding: 0.45rem 0.5rem;
  border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
  background: var(--c-bg); color: var(--c-text); line-height: 1.2;
}
.rent-day-date { font-weight: 600; }
.rent-day-free { font-size: 0.85rem; color: var(--c-muted); }
/* Booked carries its signal in the ground and the word, never a muted ink on a muted ground. */
.rent-day.is-full { background: var(--c-soft); }
.rent-day.is-full .rent-day-free { color: var(--c-text); }
/* How a hire changes hands, where the item offers both. Two plain radios in a fieldset: the choice
   is small, and a segmented control that only works once a script has run is not one. */
.rent-handover { display: grid; gap: 0.35rem; margin: 0; padding: 0; border: 0; min-width: 0; }
.rent-handover legend { padding: 0; margin-bottom: 0.25rem; font-weight: 600; }
.rent-handover label { display: flex; align-items: center; gap: 0.5rem; }

@media (max-width: 40rem) {
  .evt-book-pax-row { grid-template-columns: 1fr auto 3.75rem; }
  .evt-guide-facts { grid-template-columns: repeat(2, 1fr); }
  .slot-times { grid-template-columns: repeat(auto-fill, minmax(min(5rem, 100%), 1fr)); }
}

/* Payments in test mode, emitted on every page by the storefront skeleton. Its colours were an
   inline `style=` attribute — a mode-static amber invisible to every sweep here — and the words
   were set in `system-ui` rather than in the theme's own type. The signal is the border; the
   text is ordinary body contrast by construction. */
.payments-test-banner {
  padding: 0.45rem 1rem;
  text-align: center;
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--c-text);
  background: var(--c-soft);
  border-bottom: 2px solid var(--c-warn);
}

/* --------------------------------------------------------------------------------------------
   The catalogue filter panel (`storefront/partials/_facets.html`).

   Shared markup no theme overrides, so `commerce.css` owns it for all seven — the arrangement
   the currency and language switchers have, and for the reason `summit` taught: six themes each
   writing a private copy is six chances for the seventh to have none. A theme tweaks from
   `body.theme-<name> .facets`, which wins because this file is linked after the theme's.
   -------------------------------------------------------------------------------------------- */
.facets-panel {
  margin-bottom: 1.5rem;
  border: 1px solid var(--c-line);
  border-radius: var(--c-radius);
  background: var(--c-soft);
}
.facets-summary {
  display: flex; align-items: center; gap: 0.6rem;
  padding: 0.7rem 1.15rem;
  font-size: 0.9rem; font-weight: 600; color: var(--c-text);
  cursor: pointer; list-style: none;
}
.facets-summary::-webkit-details-marker { display: none; }
/* The summary's heading reads as the summary's label. Every theme styles `h2` (depot's rail
   paints one as a banded caption at (0,2,1)), so this names three classes to stay a label. */
body .facets-panel > .facets-summary > h2.facets-title {
  margin: 0; padding: 0; border: 0; background: none;
  font: inherit; letter-spacing: normal; text-transform: none; color: inherit;
}
/* The chevron is drawn, not the browser's triangle, so it turns with the panel and takes the ink. */
.facets-summary::after {
  content: ""; width: 0.45rem; height: 0.45rem; margin-left: auto;
  border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
  transform: rotate(45deg); transition: transform 0.15s ease;
}
.facets-panel[open] > .facets-summary::after { transform: rotate(-135deg); }
.facets-summary-on {
  padding: 0.05rem 0.5rem; border-radius: 999px; font-size: 0.75rem;
  background: var(--c-text); color: var(--c-bg);
}
.facets {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(min(11rem, 100%), 1fr));
  align-items: start;
  padding: 0.25rem 1.15rem 1rem;
}
.facet { margin: 0; padding: 0; border: 0; min-width: 0; }
.facet legend { padding: 0; margin-bottom: 0.4rem; font-size: 0.85rem; font-weight: 600; color: var(--c-text); }
.facet-values { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.2rem; max-height: 12rem; overflow-y: auto; }
/* The whole row is the target, not the 1rem box at the start of it. */
.facet-value { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0; font-size: 0.9rem; cursor: pointer; }
.facet-value input { flex: none; }
.facet-value > span:first-of-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.facet-count { margin-left: auto; font-size: 0.78rem; color: var(--c-muted); font-variant-numeric: tabular-nums; }
.facet-range { display: flex; align-items: center; gap: 0.4rem; }
.facet-range .input { min-width: 0; }
.facet-actions { grid-column: 1 / -1; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
@media (max-width: 640px) { .facets { grid-template-columns: 1fr; } }

/* The sort links are one word or three ("Price: low to high"), and every theme lays them in a
   flex row. Left to shrink, a phone broke each label over two lines inside its own link. A
   label stays whole and the row wraps between labels instead. `body` so a theme's own
   `.sort-links` (0,2,1) keeps its gaps and colours while this still reaches the wrap. */
body .sort-links { flex-wrap: wrap; row-gap: 0.35rem; }
body .sort-links a, body .sort-links span { white-space: nowrap; }

/* On a phone the sort order is a dropdown (`partials/_sort_select.html`), as shoppers expect: a
   wrapping paragraph of five sort words is a second menu to read before the products. Every
   theme sets `body.theme-<name> .sort-links { display: flex }` at (0,2,1), so hiding it needs the
   doubled class to tie that and win by being linked later. The label stays visible, because a
   bare select reading "Newest" does not say what it does. */
body .sort-select { display: none; }
@media (max-width: 40rem) {
  body .sort-links.sort-links { display: none; }
  body .sort-select.sort-select { display: flex; align-items: center; gap: 0.5rem; }
  body .sort-select label { font-size: 0.85rem; color: var(--c-muted); white-space: nowrap; }
  /* The shared `select` rule above only draws the chevron: every other part of a select's box
     comes from a theme's form rules, and this one is in no form. Without these it rendered as
     bare words with an arrow beside them. */
  body .sort-select select {
    min-width: 0; max-width: 100%;
    padding: 0.5rem 2.1rem 0.5rem 0.75rem;
    border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
    background-color: var(--c-bg); color: var(--c-text);
    font: inherit; font-size: 0.9rem; line-height: 1.3;
  }
  body .sort-select select:focus-visible {
    outline: none; border-color: var(--c-accent);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 35%, transparent);
  }
}

/* "Email me when it's back" (`storefront/partials/_back_in_stock.html`). Shared markup no theme
   overrides, so this file owns it for all seven. */
.notify-me {
  margin: 0 0 1.5rem;
  padding: 1rem 1.15rem;
  border: 1px solid var(--c-line);
  border-radius: var(--c-radius);
  background: var(--c-soft);
}
.notify-me h3 { margin: 0 0 0.25rem; font-size: 1rem; }
.notify-me p { margin: 0 0 0.75rem; color: var(--c-muted); font-size: 0.9rem; }
.notify-form { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.notify-form .input { flex: 1 1 14rem; min-width: 0; }

/* Merchant-chosen recommendations. `.cross-sells` is on the cart page and `.upsells` on the
   product page; both are shared markup no theme overrides, so this file owns them for all
   seven — the arrangement the reviews block and the course panel already have. */
/* Spaced with `gap`, not a margin on the heading: every theme resets `h2`/`h3` to `margin: 0` at
   (0,1,2), which beat `.upsells h3` at (0,1,1), so "Consider instead" sat flush on its list. */
.cross-sells { display: grid; gap: 1.25rem; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--c-line); }
.cross-sells h2 { margin: 0; }
/* Compact rows, not a second grid of cards: a shopper reading this has already found something
   they were willing to buy, and matching the main product's visual weight competes with it. */
.upsells { display: grid; gap: 0.5rem; margin: 0 0 1.5rem; }
.upsells h3 { margin: 0; font-size: 1rem; }
.upsell-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--c-line); border-radius: var(--c-radius); overflow: hidden; }
.upsell-list li + li { border-top: 1px solid var(--c-line); }
.upsell-list a { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: 0.6rem 0.9rem; text-decoration: none; }
.upsell-list a:hover { background: var(--c-soft); }
.upsell-name { color: var(--c-text); }
.upsell-price { color: var(--c-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }

/* Which options are left, on a card (`storefront/partials/_option_pills.html`). One owner for all
   twelve themes; a theme restyles from `body.theme-<name> .option-pill`.

   `body ul.option-pills` and `body li.option-pill`, because every theme resets `ul`/`li` at
   (0,1,2) — a one-class rule on a list here is a rule that is in the file and never applies,
   which this stylesheet has learned three times.

   A sold-out pill is **struck through, not dimmed**: fading it is how a card ends up with text
   below the contrast floor, and the storefront sweep measures whatever is painted. Its words go
   through `--c-muted`, the same pair `.pill` has always used. */
body ul.option-pills {
  display: flex; flex-wrap: wrap; gap: 0.3rem;
  margin: 0.5rem 0 0; padding: 0; list-style: none;
}
body li.option-pill {
  padding: 0.1rem 0.5rem; border: 1px solid var(--c-line);
  border-radius: 999px; font-size: 0.75rem; line-height: 1.5;
  color: var(--c-text); background: var(--c-bg);
}
body li.option-pill.is-sold-out {
  color: var(--c-muted); background: var(--c-soft);
  text-decoration: line-through;
}
body li.option-pill-more { border-style: dashed; color: var(--c-muted); }

/* Save for later (`storefront/partials/_wishlist_button.html`). Included by all three copies of
   the product card, so this file owns it for all seven themes — a theme that adds its own card
   partial and forgets the include loses the control, which `tests/themes/test_wishlist.py`
   fails on. */
.product-card { position: relative; }
.wish-form { position: absolute; top: 0.5rem; right: 0.5rem; z-index: 1; margin: 0; }
.wish-btn {
  display: grid; place-items: center;
  width: 2.1rem; height: 2.1rem; padding: 0;
  border: 1px solid var(--c-line); border-radius: var(--c-radius-pill, 999px);
  background: var(--c-bg); color: var(--c-muted);
  cursor: pointer;
}
.wish-btn:hover { color: var(--c-text); border-color: var(--c-line-strong); }
.wish-btn.is-saved { color: var(--c-danger, var(--c-accent)); border-color: currentColor; }
.wish-btn:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
.wish-btn .icon { width: 1.05rem; height: 1.05rem; }
/* On the product page it sits in the flow beside the buy button, not over an image. */
.product-buy .wish-form { position: static; }
/* The header's link to the saved list, beside Cart. */
.saved-link { white-space: nowrap; }

/* ---- Shared shell classes that used to live in one theme ---------------------------------
   `.container.narrow`, the sign-in furniture, `.site-main`, `.cart-link` and `.course-panel`
   are all emitted by templates **no theme overrides** — and every one of them was defined only
   in the old default theme's stylesheet. The pooled half of
   `tests/themes/test_shared_template_classes.py` could not see that (one definition anywhere
   counts as styled for everybody), so replacing the themes is what surfaced it: a class one
   theme happens to define is a class the other six render as browser defaults.

   Shared markup has one owner, and this is it. */
/* `body ` is load-bearing: a theme sets its shell width from `body.theme-<name> .container`,
   which is (0,2,1), and a bare `.container.narrow` is (0,2,0) — so the narrow variant lost and
   the contact form, the sign-in card and the order lookup all ran the full shell width. With
   the element selector the two tie, and commerce.css is linked *after* the theme stylesheet, so
   this wins. Measured by `test_shared_controls_browser.py`, which is why it was caught. */
body .container.narrow { width: min(46rem, calc(100% - 2.5rem)); }
/* A hook rather than a component — but it earns one rule: without it a short page (a 404, an
   empty basket) leaves the footer floating in the middle of the viewport. */
.site-main { display: block; min-height: 50vh; }
.cart-link { position: relative; }

/* The sign-in and register cards. Every theme draws the card's border and ground
   (`body.theme-<name> .auth-card`), and nothing drew the inside: the rule that did lived in a
   retired theme's stylesheet, so on all twelve the heading, the fields and the button sat on the
   border. The spacing is written against the themes' `p` and `h1` margin reset at (0,1,2), and
   the button rule is `.auth-card.form` because the card *is* the form — the old
   `.auth-card .form .btn` looked for a form inside it and matched nothing. */
body .auth-card { padding: clamp(1.5rem, 6vw, 2.25rem); }
body .auth-card h1 { margin: 0 0 0.35rem; }
body .auth-card p.auth-sub { margin: 0 0 1.5rem; }
body .auth-card.form .btn { width: 100%; margin-top: 0.25rem; }
body .auth-card p.auth-foot { margin: 1rem 0 0; }
.auth-section { padding: clamp(2.5rem, 6vw, 4.5rem) 0; display: grid; justify-items: center; }
.auth-section > * { width: min(26rem, 100%); }
.form-error {
  margin-bottom: 1rem; padding: 0.75rem 1rem;
  border: 1px solid var(--c-line-strong); border-left: 3px solid var(--c-danger);
  border-radius: var(--c-radius-sm);
  background: var(--c-soft); color: var(--c-text); font-size: 0.9rem;
}
.form-error ul { margin: 0; padding-left: 1rem; }
.auth-foot { font-size: 0.875rem; color: var(--c-muted); text-align: center; }
.auth-foot a { color: var(--c-accent); font-weight: 600; }

/* `_course_panel.html` is a modifier on `.product-section`, which is styled above — it needs
   only the one thing that is genuinely its own. */
.product-section.course-panel .course-outcomes { margin-top: 0.75rem; }

/* ---- Cart arrangements ------------------------------------------------------------------
   `cart.html` is now a theme's own template, but only the *arrangement* is: the line items and
   the summary stay in `partials/_cart_lines.html` and `_cart_summary.html`, because the htmx
   targets and the Alpine stepper are fiddly, identical for everybody, and seven copies of them
   would be seven places for one to drift. These are the shapes those two parts are put into. */
.cart-stack { display: grid; gap: clamp(1.5rem, 3vw, 2.5rem); }
.cart-stack .summary-card { max-width: 26rem; margin-left: auto; }
.cart-stack-summary-first .summary-card { margin-left: 0; margin-right: auto; }
/* The summary column on the left, which is a different reading order rather than a mirror: the
   totals are what a shopper checks first on these themes. */
.commerce-grid.cart-summary-first { grid-template-columns: 20rem minmax(0, 1fr); }
.commerce-grid.cart-summary-first > .summary-card { order: -1; }
@media (max-width: 860px) {
  .commerce-grid.cart-summary-first { grid-template-columns: 1fr; }
  .cart-stack .summary-card, .cart-stack-summary-first .summary-card { max-width: none; }
}

/* ---- Scroll reveal ------------------------------------------------------------------------
   The hidden state is behind `html.js-reveal`, which `static/js/reveal.js` adds. With
   JavaScript blocked, broken or slow, none of this applies and every element is visible in its
   final position — which is also why the browser sweeps, which strip scripts, measure a page
   with nothing hidden. A reveal written the other way round (hidden here, shown by script) is a
   blank shop whenever anything goes wrong, and a screenshot test cannot see it because the test
   runs the script.

   `prefers-reduced-motion` is belt and braces: the script already refuses to add the class, and
   this makes sure a cached class from a previous visit cannot animate anything either.

   **The revealed rule repeats the hidden selector and appends `.is-revealed`**, rather than
   being one short `.js-reveal .is-revealed`. That short version is (0,2,0) and
   `.js-reveal .builder-page .pb-block` is (0,3,0), so every block on every built home page was
   given the class, kept `opacity: 0`, and stayed invisible for good — the reveal turning into
   the blank shop it is written to avoid. Only a browser can see it, which is what
   `tests/themes/test_reveal_browser.py` is for. */
.js-reveal [data-reveal],
.js-reveal .builder-page .pb-block,
.js-reveal .product-card,
.js-reveal .section-head,
.js-reveal .listing-hero,
.js-reveal .newsletter {
  opacity: 0;
  transform: translateY(var(--reveal-shift, 14px)) scale(var(--reveal-scale, 1));
  transition:
    opacity var(--reveal-duration, 520ms) var(--reveal-ease, cubic-bezier(0.22, 0.61, 0.36, 1)),
    transform var(--reveal-duration, 520ms) var(--reveal-ease, cubic-bezier(0.22, 0.61, 0.36, 1));
  will-change: opacity, transform;
}
.js-reveal [data-reveal].is-revealed,
.js-reveal .builder-page .pb-block.is-revealed,
.js-reveal .product-card.is-revealed,
.js-reveal .section-head.is-revealed,
.js-reveal .listing-hero.is-revealed,
.js-reveal .newsletter.is-revealed {
  opacity: 1;
  transform: none;
  will-change: auto;
}
@media (prefers-reduced-motion: reduce) {
  .js-reveal [data-reveal],
  .js-reveal .builder-page .pb-block,
  .js-reveal .product-card,
  .js-reveal .section-head,
  .js-reveal .listing-hero,
  .js-reveal .newsletter {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* ---- Motion under the pointer ---------------------------------------------------------
   The one piece of motion every theme in the reference set has, and the one this shop had
   none of: a product photograph that moves a little when you point at it. It is worth having
   for a reason beyond decoration — the card is a link whose whole target area is a picture,
   and the movement is what says so.

   Three things keep it honest:

   - **It is a `transform` on the image and nothing else moves.** Growing the card would
     reflow the grid; growing the image inside a clipped frame does not touch layout, so a
     row of cards cannot jitter as the pointer crosses it.
   - **`@media (hover: hover)`.** On a touch screen `:hover` sticks after a tap, so without
     this the last card somebody touched stays zoomed until they touch another one.
   - **`:focus-within` gets it too**, because the card is reached by keyboard as well, and an
     affordance only pointers can see is one half of the users missing it.

   `--hover-zoom` is per theme, like `--reveal-*`: press barely moves, studio scales, depot
   does not move at all because a parts catalogue is a tool rather than a shop window. A theme
   setting `1` opts out without needing a rule to undo. */
.product-media { overflow: hidden; }
.product-media img {
  transition: transform var(--hover-duration, 620ms)
    var(--hover-ease, cubic-bezier(0.22, 0.61, 0.36, 1));
}
@media (hover: hover) {
  .product-card:hover .product-media img,
  .product-card:focus-within .product-media img {
    transform: scale(var(--hover-zoom, 1.045));
  }
}
@media (prefers-reduced-motion: reduce) {
  .product-media img { transition: none; }
  .product-card:hover .product-media img,
  .product-card:focus-within .product-media img { transform: none; }
}

/* ---- Menu submenus ------------------------------------------------------------------------
   Structural only. Each theme styles `.site-nav` itself — seven headers genuinely do look
   different — so what lives here is the part that is the same everywhere: where the panel sits
   and what opens it. Colours come from the palette tokens, so a submenu follows light and dark
   without any theme writing a rule.

   commerce.css is linked *after* the theme stylesheet, so these win at equal specificity and a
   theme tweaks them from `body.theme-<name> .nav-submenu { … }`.

   **No JavaScript, and that is the design.** `:hover` opens it for a pointer, `:focus-within`
   opens it for a keyboard. A menu that needs a script to open is a menu some people cannot
   open, and this one is above the fold on every page of the shop. */
.site-nav .nav-group { position: relative; display: inline-flex; align-items: center; }
.site-nav .nav-group-label { cursor: default; }
.site-nav .nav-submenu {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 40;
  display: grid;
  min-width: 12rem;
  padding: 0.4rem;
  gap: 0.1rem;
  background: var(--c-bg);
  border: 1px solid var(--c-line);
  border-radius: var(--c-radius-sm);
  box-shadow: var(--c-shadow);
  /* Hidden without `display: none`, so the links stay in the accessibility tree and a screen
     reader can still reach them — and so `:focus-within` has something to focus. */
  opacity: 0;
  visibility: hidden;
  transform: translateY(-0.25rem);
  transition: opacity 120ms ease, transform 120ms ease, visibility 120ms;
}
.site-nav .nav-group:hover .nav-submenu,
.site-nav .nav-group:focus-within .nav-submenu {
  opacity: 1;
  visibility: visible;
  transform: none;
}
.site-nav .nav-submenu a {
  padding: 0.4rem 0.6rem;
  border-radius: var(--c-radius-sm);
  white-space: nowrap;
}
.site-nav .nav-submenu a:hover,
.site-nav .nav-submenu a:focus-visible { background: var(--c-soft); }
/* **The panel owns its ink, and the leading `body` is load-bearing.** A theme colours
   `.site-nav a` for the *bar* — and `voyage`'s bar is a dark chrome band, so its rule is
   `color: var(--color-surface)` at (0,2,2). These links are inside `.site-nav`, so on a panel
   grounded in `--c-bg` that is near-white ink on a near-white card: **1.0:1, in both colour
   schemes**, five of six links invisible, and in dark mode the same thing the other way round.
   Exactly what `.site-search` did on the same theme for the same reason. A bare
   `.site-nav .nav-submenu a` is (0,2,1) and loses; with `body` it ties at (0,2,2) and commerce
   is linked *after* the theme, so it wins. A theme that wants its own panel ink writes
   `body.theme-<name> .site-nav .nav-submenu a`, which is (0,3,2) — and voyage does, because
   the honest fix there is a panel that belongs to the band rather than to the page. */
body .site-nav .nav-submenu a { color: var(--c-text); }

/* The mega panel — the same one level of nesting, laid out in columns.
   `menu()` only builds columns for a parent set to `mega` that has more than three children, so
   this never draws a wide empty box over a short list. `auto-fit` rather than a fixed count so
   a merchant who marks two columns gets two, not four with two empty. */
.site-nav .nav-submenu-mega {
  grid-template-columns: repeat(auto-fit, minmax(min(9.5rem, 100%), 1fr));
  gap: 0.25rem 1.75rem;
  padding: 1.1rem 1.25rem 1.25rem;
  /* Anchored to the header rather than to the entry, so a panel opening near the right edge
     does not run off the page — the one thing a wide dropdown does that a narrow one never has
     to. `max-width` keeps it inside the shell on a small laptop. */
  left: auto;
  min-width: min(44rem, 90vw);
  max-width: 90vw;
}
.site-nav .nav-submenu-mega .nav-column { display: grid; gap: 0.1rem; align-content: start; }
/* The column head is an ordinary link that happens to start a column, so it is marked by weight
   and spacing rather than by being a different kind of thing. */
.site-nav .nav-submenu-mega .nav-column-head {
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-size: 0.72rem;
  color: var(--c-muted);
  margin-bottom: 0.2rem;
}
/* A short flag beside a label. It takes the accent *pair* from the palette — never a colour a
   merchant types — for the reason the builder's tones give: a literal is the same value in
   light and dark, and no contrast test can see a per-shop override. */
.nav-badge {
  display: inline-block;
  margin-inline-start: 0.4rem;
  padding: 0.1rem 0.35rem;
  border-radius: 3px;
  background: var(--color-accent);
  color: var(--color-accent-ink);
  font-size: 0.6rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  vertical-align: middle;
  white-space: nowrap;
}
/* On a narrow screen there is no room beside the parent and no hover to open it with, so the
   panel becomes an indented list that is simply always there. A dropdown that needs a hover is
   a dropdown a phone cannot open. */
/* **The phone shape is structural, so it outranks the themes.** A media query adds no
   specificity, and every theme now styles its own panel at `body.theme-<name> .site-nav
   .nav-submenu` — (0,3,1) — so a bare `.site-nav .nav-submenu` here would lose and a phone
   would get the desktop card: a shadowed, padded, sometimes inverted box inlined into the
   burger menu, on all seven. `.nav-group` in the middle takes these to (0,3,2), above any
   theme's panel rule; a theme that genuinely needs to tune the phone list has to say so
   explicitly, which is the right way round for a shape that is about the device rather than
   about the design. */
@media (max-width: 720px) {
  body .site-nav .nav-group { display: block; }
  /* A mega panel on a 390px screen is just a long menu, so it becomes one. */
  body .site-nav .nav-group .nav-submenu-mega {
    grid-template-columns: 1fr; min-width: 0; max-width: none; padding: 0; gap: 0;
  }
  body .site-nav .nav-group .nav-submenu {
    position: static;
    opacity: 1;
    visibility: visible;
    transform: none;
    min-width: 0;
    padding: 0 0 0 0.9rem;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    background: transparent;
    transition: none;
  }
}

/* ---------------------------------------------------------------- The shop's logo, twice
   `partials/_brand.html` puts both of a shop's logos in the markup when it has a version for
   dark backgrounds, and this picks one. The choice follows the same two inputs the palette
   does — the merchant's `color_scheme` (stamped on <html> as `data-color-scheme`) and, when that
   is `auto`, the visitor's device — so the logo and the colours behind it can never disagree.

   `data-brand-ground="inverse"` flips it, for a band a theme paints in the *opposite* of the
   page: depot's, voyage's and encore's footers are `--color-ink`, which is dark on a light
   page and light on a dark one. What decides whether a logo is legible is the ground under it,
   not the device, and `tests/themes/test_brand_logo_browser.py` measures that ground.

   `data-brand-ground="accent"` is a chip painted in the accent colour (studio, tally), where
   neither the page nor its opposite is the answer: the palette is. `theme._brand_on_accent`
   emits the two `display` values beside the colour roles, in the same light and dark `:root`
   blocks, and `:root` in those two rules is what lets them outrank the scheme rules above.

   `body` is in every selector so these outrank a theme's `body.theme-<name> .site-brand img`,
   and none of them sets anything but `display`: the size is still the theme's. */
body .site-brand .brand-logo-dark { display: none; }
body .site-brand[data-brand-ground="inverse"] .brand-logo-light { display: none; }
body .site-brand[data-brand-ground="inverse"] .brand-logo-dark { display: inline-block; }
html[data-color-scheme="dark"] body .site-brand .brand-logo-light { display: none; }
html[data-color-scheme="dark"] body .site-brand .brand-logo-dark { display: inline-block; }
html[data-color-scheme="dark"] body .site-brand[data-brand-ground="inverse"] .brand-logo-light { display: inline-block; }
html[data-color-scheme="dark"] body .site-brand[data-brand-ground="inverse"] .brand-logo-dark { display: none; }
@media (prefers-color-scheme: dark) {
  html[data-color-scheme="auto"] body .site-brand .brand-logo-light { display: none; }
  html[data-color-scheme="auto"] body .site-brand .brand-logo-dark { display: inline-block; }
  html[data-color-scheme="auto"] body .site-brand[data-brand-ground="inverse"] .brand-logo-light { display: inline-block; }
  html[data-color-scheme="auto"] body .site-brand[data-brand-ground="inverse"] .brand-logo-dark { display: none; }
}
:root body .site-brand[data-brand-ground="accent"] .brand-logo-light { display: var(--brand-logo-on-accent-light, inline-block); }
:root body .site-brand[data-brand-ground="accent"] .brand-logo-dark { display: var(--brand-logo-on-accent-dark, none); }
:where(.site-brand) .brand-logo { vertical-align: middle; }
/* **A wordmark is wider than the name it replaces, so it is capped by width as well.** Every
   theme sizes the logo by height alone (`max-height: 2–3rem; width: auto`), which is right for
   the shop's *name* the headers were designed around and wrong for an 8:1 wordmark: at 2.75rem
   tall that is 350px, which crushed counter's search box to its magnifier, pushed tally's header
   past the window and scrolled linden's sideways on a phone. With both maxima an image scales
   down proportionally, so a typical 4:1 logo is untouched and only a long one gets shorter.
   `vw` rather than `%`: a percentage inside a flex or grid item that sizes to its content
   resolves against a width the image is itself deciding — which is also how voyage's logo came
   out 0x0 on a phone, under every theme's `body.theme-<name> img { max-width: 100% }`. That rule
   is (0,1,2), so this needs real specificity rather than `:where()`, or it is a rule in the file
   that never applies. `tests/themes/test_brand_logo_layout_browser.py` measures every header. */
body .site-brand .brand-logo { max-width: min(calc(15rem * var(--logo-scale, 1)), 45vw); }
/* **The merchant chooses how big the logo is in the header** (Design → `logo_size`, stamped on
   <body> as `data-logo-size`). Every theme writes its logo height as
   `calc(<its own rem> * var(--logo-scale, 1))`, so "Standard" is exactly the design as drawn and
   every other size is a multiple of *that* theme's height rather than one number forced on
   twelve headers of different proportions. The scale is set on `.site-header` only: the footer
   draws the same partial and keeps the design's size, because a logo sized to lead the page
   is too big for the line of small print under it. The width cap above scales with it and is
   still bounded by the window, so a long wordmark grows until it would crowd the header and
   no further. */
body[data-logo-size="small"] .site-header { --logo-scale: 0.8; }
body[data-logo-size="large"] .site-header { --logo-scale: 1.4; }
body[data-logo-size="x-large"] .site-header { --logo-scale: 1.9; }
body[data-logo-size="xx-large"] .site-header { --logo-scale: 2.5; }
/* A phone header is a row shared with the menu button and the basket, so the two largest sizes
   are held back there: at 2.5 times its design height a logo was most of a 390px screen's first
   glance, and the merchant choosing "Extra large" was looking at their desktop. */
@media (max-width: 720px) {
  body[data-logo-size="x-large"] .site-header { --logo-scale: 1.5; }
  body[data-logo-size="xx-large"] .site-header { --logo-scale: 1.7; }
}
/* In a footer the brand sits in a column of known width, and at 1024px linden's is narrower than
   15rem — so there the column is the cap. A block column is not the auto-sized grid track that
   made a percentage collapse in voyage's header. */
body .site-footer .site-brand .brand-logo { max-width: min(15rem, 100%); }

/* ---------------------------------------------------------------- Social links
   `partials/_social_link.html` puts the network's glyph to the left of its name, in every footer
   and in voyage's utility bar. The glyph is `currentColor`, so it takes whatever ink the theme
   gives the link — hover included — and there is no colour here to go wrong in dark mode.
   `inline-flex` keeps the link's outer behaviour exactly as it was (inline in a row, blockified
   in the footers' grid columns). The doubled class outranks a theme's
   `body.theme-<name> .footer-col a` at (0,2,1) without naming any theme. */
body .social-link.social-link { display: inline-flex; align-items: center; gap: 0.5em; }
body .social-link .social-icon { width: 1.05em; height: 1.05em; flex: none; }

/* A saved address at checkout, with its Remove beside it. */
body .saved-address { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.75rem; }
