/* 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):not(.seat-block):not(.seat-section-choice),
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; }

/* What an agency is told under a price on the product page ("Your price, before VAT. RRP ...").
   The colour is the price's own, because this sits on whatever surface a theme put the price on
   and a mixed grey is only readable on some of them. `body p` so it is not out-ranked by a
   theme's own paragraph margins. */
body p.trade-note { margin: 0.25rem 0 0; font-size: 0.875rem; font-weight: 400; color: inherit; }
/* What a service that takes a deposit says in the same place: paid now, left, and due when. */
body p.deposit-note { margin: 0.25rem 0 0; font-size: 0.875rem; font-weight: 400; color: inherit; }
/* The price in the shop's other currencies, in the same place ("Also payable in Rs2,506.00"). */
body p.also-payable { margin: 0.25rem 0 0; font-size: 0.875rem; font-weight: 400; color: inherit; }
/* What a shop that watermarks its photographs says there about something sold as a file. */
body p.watermark-note { margin: 0.25rem 0 0; font-size: 0.875rem; font-weight: 400; color: inherit; }
.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; }
/* A notice that asks for something ("Email me a link"): on the button, since themes reset `p`. */
body .notice p + .btn { margin-top: 0.75rem; }
.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);
}
/* The palette's muted-ink role, as the site search box does: `--c-muted` is a theme token, and on
   a dark ground it measured 3.8–3.95:1 under the phone box's example number on six themes. */
.input::placeholder { color: var(--color-ink-muted, 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(--hr-shown, 0px) + 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); }
/* The bags an order goes out in: a row of its own under delivery, whose list of bags may wrap. */
.totals .packaging > :first-child { min-width: 0; overflow-wrap: anywhere; }
.totals .hint { color: var(--c-muted); font-size: 0.8rem; }
/* Reward points (`partials/_points.html`, `_order_points.html`): what a basket spends and what it
   earns, under the discount code, and the two quiet lines in an order's totals. `body` because
   every theme resets `p` margins at (0,1,2). */
.points-offer > .discount-applied { margin-top: 0.75rem; }
body .points-offer > .hint { margin: 0.6rem 0 0; font-size: 0.8rem; color: var(--c-muted); }
.totals .points { align-items: baseline; }
/* The basket's currency, offered only when every line has a price in more than one. */
body .basket-currency { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 1.25rem; }
body .basket-currency select { flex: 1; min-width: 6rem; }
.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; }
/* When a line is for a particular time (a table, an appointment, a hire): `{{ line|line_when }}`.
   A span in a list row would run on from the name, so it is a block. */
body span.line-when { display: block; font-size: 0.85rem; font-weight: 400; color: var(--c-muted); margin-top: 0.15rem; }
/* What a line says about its own money (`{{ line|line_note }}`): a booking's line that is only
   the deposit says what is left and by when. Drawn as the time above it is. */
body span.line-note { display: block; font-size: 0.85rem; font-weight: 400; 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; }
/* Beside the buy button the stepper is as tall as the button. Seven themes centre the row, so a
   45px outline sat in the middle of a 55px button and the two boxes missed by a few pixels above
   and below (`geometry.py`, `uneven-row`). One rule for every theme, so it is written to win:
   a theme's own `.qty` is (0,2,1) and its `.qty button` (0,2,2).

   Stretching the box is half of it. The buttons and the number inside have to fill it too, and
   an explicit height is the one thing `align-items: stretch` does not override, so theirs is
   taken off (the gap that leaves under them is what `test_quantity_picker_browser.py` measures,
   and depot met it first). `min-height` keeps the buttons a size to press where the stepper has
   a line to itself. */
body form.buy-form > .qty { align-self: stretch; align-items: stretch; }
body form.buy-form > .qty button { height: auto; min-height: 2.25rem; }
body form.buy-form > .qty input { height: auto; }
.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); }
/* An extra bought with something else (an event's beer or wetsuit). It is a variant of the event's
   product, so it shares the event's name and photograph; it is drawn smaller, hanging off the line
   it belongs to, led by its own name and tagged. The tag is the palette's ink on its soft ground in
   a border, the pairing a palette change cannot break. */
.cart-line.is-extra { grid-template-columns: 3rem minmax(0, 1fr) auto; gap: 1rem; margin-inline-start: 1.25rem; padding-block: 0.85rem; padding-inline-start: 1rem; border-inline-start: 2px solid var(--c-line-strong); }
.cart-line.is-extra .cart-thumb { width: 3rem; }
body .cart-line.is-extra .cart-line-body h3 { font-size: 0.95rem; }
.line-extra-tag { display: inline-block; vertical-align: middle; margin-inline-start: 0.35rem; padding: 0.05rem 0.55rem; border: 1px solid var(--c-line-strong); border-radius: 999px; background: var(--c-soft); color: var(--c-text); font-family: var(--font-body, system-ui, sans-serif); font-size: 0.72rem; font-weight: 600; line-height: 1.5; }
body span.line-extra-for { display: block; font-size: 0.85rem; font-weight: 400; color: var(--c-muted); margin-top: 0.15rem; }
.checkout-summary .summary-lines li.is-extra { padding-inline-start: 0.85rem; border-inline-start: 2px solid var(--c-line-strong); }
.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-body .cart-line.is-extra { grid-template-columns: 2.75rem minmax(0, 1fr) auto; margin-inline-start: 0.75rem; padding-inline-start: 0.75rem; }
.cart-drawer-body .cart-line.is-extra .cart-thumb { width: 2.75rem; }
.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); }
/* The widget takes a line of its own inside a form laid out as a row.
   It is `hidden` until Cloudflare has a question, and every theme's `.newsletter-form` is a
   wrapping flex row of an input and a button — so the moment it *is* shown it arrives as a third
   item in a row nobody laid out for it: a 300px white branded box (Cloudflare draws none
   narrower) squeezing the email field to its placeholder, on the one press where the visitor has
   to be able to read it. `flex-basis: 100%` on a wrapping row and `grid-column: 1 / -1` on a grid
   put it and its note on their own line wherever a form is either; on a block form both are
   inert. The margin is for `press`, `encore` and `voyage`, whose rows are `gap: 0` because their
   input and button are deliberately attached.
   Giving it the line is also why `turnstile.js` asks for `size: "normal"` and not `flexible`:
   flexible means *fill the parent*, so the two together drew a Cloudflare banner the full width
   of the form. Do not cap that here with a `max-width` — a flex item's line breaking uses its
   flex-basis *clamped by max-width*, so a 300px cap would put it back beside the email field.
   The size belongs to the widget, the line belongs to the form.
   No sweep can see this: the widget is a Cloudflare iframe that loads in no test, and it is
   `hidden` on every page the sweeps do render. */
body .turnstile,
body .turnstile-note {
  flex-basis: 100%;
  grid-column: 1 / -1;
}
/* The widget sits directly above what it holds — the submit button of a stacked form, or the whole
   input-and-button row of an inline one (it is that form's first child) — so the space it needs is
   below it. Checkout's `.place-order` is a grid whose `gap` already does this. */
body .turnstile:not([hidden]) { margin-block: 0 0.6rem; }
body .place-order .turnstile:not([hidden]) { margin-block: 0; }
/* Its 300px is the widget's and never the layout's. Cloudflare's box is a fixed 300x65, which is
   wider than the room in a booking card on a phone (about 288px at 390, 258 at 360) and than a
   footer newsletter row on a narrow theme. As an ordinary child it is part of its container's
   *minimum* width, so a grid track sized `1fr` grew to fit it and the whole page scrolled sideways
   (harbour, depot, syllabus and voyage's booking panel). `contain: inline-size` makes the
   placeholder's own intrinsic width nothing, so it takes the width its container has and can
   never widen it; `turnstile.js` then scales a widget that does not fit that width. Neither half
   is visible to a sweep: the widget is an iframe that loads in no test. */
body .turnstile { contain: inline-size; }
/* 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 0 0.6rem; 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 widget is 300px and draws no narrower, 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; }
body .plain-list { list-style: none; margin: 0; padding: 0; }
body .plain-list li { padding: 0.75rem 0; border-top: 1px solid var(--c-line); }
body .plain-list li:first-child { border-top: 0; padding-top: 0; }
.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 --------------------------------------------------------------------------------
   A quiet list, not a filled pill: the current item is a 2px accent rule at the inline start and
   stronger ink, which reads as "you are here" without a block of colour competing with the seven
   other things a `--c-accent-soft` fill is used for elsewhere on the page (a chip, a discount
   banner). The rule sits on every row, transparent until it is the current one, so nothing shifts
   width when it lights up. "Sign out" is set apart below a hairline divider — it is not a fourth
   or fifth destination, it ends the list. */
.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.1rem; }
.account-nav a {
  padding: 0.55rem 0.9rem; border-radius: var(--c-radius-sm); text-decoration: none;
  color: var(--c-muted); font-weight: 500; border-inline-start: 2px solid transparent;
}
.account-nav a:hover { background: var(--c-soft); color: var(--c-text); }
.account-nav a[aria-current="page"] { border-inline-start-color: var(--c-accent); color: var(--c-text); font-weight: 600; }
.account-nav form { margin-top: 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--c-line); }
.account-nav button { width: 100%; text-align: left; padding: 0.55rem 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; }
/* My downloads: whole galleries above the single files, each table with room between. */
body h2.downloads-heading { margin: 0 0 1rem; }
body .table-scroll + .table-scroll { margin-top: 2rem; }
.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; }
  /* The vertical list's inline-start rule has nowhere to sit in a row, so the current item is
     underlined instead — the same "you are here" mark `.text-link`'s tab-strip cousins use — and
     the whole thing scrolls sideways rather than wrapping onto several lines, which is what a
     six-entry menu did on a 360px phone before this. "Sign out" stops being a full-width button
     and becomes one more item in the row, in the same quiet ink as the rest, because a strip has
     no floor to set it apart below. */
  .account-nav {
    flex-direction: row; flex-wrap: nowrap; overflow-x: auto; gap: 0.25rem;
    -webkit-overflow-scrolling: touch; padding-bottom: 0.25rem;
  }
  .account-nav a {
    border-inline-start: 0; border-bottom: 2px solid transparent; white-space: nowrap;
  }
  .account-nav a[aria-current="page"] { border-bottom-color: var(--c-accent); }
  .account-nav form { margin-top: 0; padding-top: 0; border-top: 0; flex: none; }
  .account-nav button { width: auto; white-space: nowrap; }
}
@media (max-width: 600px) {
  .cart-line { grid-template-columns: 4rem minmax(0, 1fr); }
  .cart-line-price { grid-column: 2; text-align: left; }
  .cart-line.is-extra { grid-template-columns: 3rem minmax(0, 1fr); margin-inline-start: 0.5rem; padding-inline-start: 0.75rem; }
  .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; }

/* **Searching.** `search.js` puts `is-searching` on the form from the keystroke until the answer
   (and from a submit until the results page lands). Between the two the box used to say
   nothing at all, which on a phone connection is what a slow search looks like.
   The magnifier *becomes* the ring rather than being covered by one: its own box, emptied and
   given a border, so it is where the magnifier was on every theme whatever padding that theme
   gives the button (five of them pad it unevenly, and a ring centred on the button would sit
   beside the glyph it replaces), nothing moves, and it is drawn in `currentColor` — the ink
   each header has already had to get right for the magnifier. */
.site-search.is-searching .site-search-icon {
  box-sizing: border-box;
  border: 2px solid currentColor;
  border-inline-end-color: transparent;
  border-radius: 50%;
  animation: site-search-turn 0.7s linear infinite;
}
.site-search.is-searching .site-search-icon > * { visibility: hidden; }
/* An answer still on show while the next is on its way: there, and visibly not the last word. */
.site-search.is-searching .site-search-panel .site-search-group { opacity: 0.5; }
@keyframes site-search-turn { to { transform: rotate(360deg); } }
@keyframes site-search-wait { from { opacity: 1; } to { opacity: 0.3; } }
/* Nothing turns for somebody who asked for nothing to: the whole ring fades and returns. */
@media (prefers-reduced-motion: reduce) {
  .site-search.is-searching .site-search-icon {
    border-inline-end-color: currentColor;
    animation: site-search-wait 0.9s ease-in-out infinite alternate;
  }
}

/* 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; }
}

/* **The menu gives way before the header wraps.** harbour, vault, studio, petal and press draw
   the header as one wrapping flex row: brand, menu, search box, then the language picker, Saved,
   basket and account. A flex row wraps on each item's *base size*, and the menu's is its full
   width, so on a shop with a real menu, search box and picker the row wrapped below about 1100px
   — the actions dropped onto a second row at the left, or, with the logo set to "On the right",
   the logo did — while `nav_overflow.js`, which folds the menu into More when *its own items*
   run out of room, saw a menu that fitted and did nothing.
   Basis 0 takes the menu out of the wrapping sum (its floor is its widest single link, so a header too narrow even for that wraps as it always did instead of drawing a link over the search box), and a very large grow lets it have the free
   space before the search box does, up to `max-content` — so with room it is exactly the width it
   was, and without it the menu narrows and More takes the rest. It may also wrap *within* its own
   column: with the script blocked nothing folds, and a narrowed menu that could not wrap would
   draw its links over the search box — worse than the row wrapping, which is what this replaces.
   `nav_overflow.js` reads a wrapped menu as "does not fit", so with the script the menu folds. Above the phone drawer's own
   720px breakpoint, where all five switch to a stacked list. `body:is(…)` is (0,3,1): over each
   theme's `.site-nav` and `[data-brand-position] .header-inner > .site-nav` for `flex`, while the
   margins and `order` they set still apply. `test_header_one_row_browser.py` holds it. */
@media (min-width: 721px) {
  body:is(.theme-harbour, .theme-vault, .theme-studio, .theme-petal, .theme-press)
    .header-inner > .site-nav {
    flex: 1000 1 0; min-width: min-content; max-width: max-content; flex-wrap: wrap; row-gap: 0.4rem;
  }
}

/* ------------------------------------------------- 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.
     **That is only right while the box ends near the edge the panel grows away from.** On a
     phone it does not — depot ends the search row with its menu button, five themes put the
     box in the menu's drawer — and there this rule alone started the panel off the left of the
     screen. Nothing here can know where a box has been put, so `search.js` (`place`) measures
     when it opens the panel: no wider than the room, and moved to be inside it. A theme that
     knows its own row says so in CSS as well (depot). */
  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/_language_switcher.html` (and the currency picker, removed now that a shop prints
   its own prices and a shopper chooses a currency on the basket), 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; }
/* A merchant's own datasheet section (Moldings, Care). It is *not* `.product-info`: that is the buy
   column's class, every theme positions it and studio makes it sticky, so a section wearing it
   slid over the one below as the page scrolled. Nothing to paint — `.product-section` is the look —
   and the rule exists so the class sweep sees the name is defined on purpose. */
.product-extra { }
/* A hairline between sections, not a rule: `--c-line` is a full-strength border on several themes
   (studio's is a mid grey), which drew a hard line across the column directly over a small heading. */
.product-section + .product-section {
  padding-top: clamp(1.5rem, 4vw, 2.5rem);
  border-top: 1px solid color-mix(in srgb, var(--c-line) 40%, transparent);
}
.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.5rem 0 0; padding-top: 1.25rem;
  border-top: 1px solid color-mix(in srgb, var(--c-line) 40%, transparent);
}
.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; }

/* A specification table and the room scenes: the shared markup of `_product_sections.html`.
   `body ` on the cell rules is load-bearing for the reason it always is here — every theme resets
   `th`, `td` and `p` from `body.theme-<name>` at (0,1,2) or above. */
.spec-table { width: 100%; max-width: 44rem; border-collapse: collapse; }
body .spec-table th, body .spec-table td { padding: 0.65rem 0.75rem; text-align: left; vertical-align: top; border-bottom: 1px solid var(--c-line); overflow-wrap: anywhere; }
body .spec-table th { width: 38%; font-weight: 600; color: var(--c-muted); }
.spec-table tr:first-child th, .spec-table tr:first-child td { border-top: 1px solid var(--c-line); }
.room-scenes { list-style: none; margin: 0; padding: 0; display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr)); }
.room-scene figure { margin: 0; }
.room-scene img { display: block; width: 100%; height: auto; border-radius: var(--c-radius); }
body .room-scene figcaption { margin-top: 0.4rem; font-size: 0.85rem; color: var(--c-muted); }

/* ---- 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; }
/* A reservation's row names the booking in an <h3> and the groups of rows ("Coming up",
   "Earlier") are <h2>s. Every theme resets headings to `margin: 0` at (0,1,2), so these are
   written `body …` or they never apply. */
body .booking-list h3 { font-size: 1.05rem; margin: 0 0 0.5rem; }
body .booking-list h3 a { text-decoration: none; color: inherit; }
body .booking-list h3 a:hover { color: var(--c-accent); }
body h2.booking-group { margin: 0 0 0.75rem; }
body .booking-list + h2.booking-group { margin-top: 1.75rem; }

/* 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 img { max-width: 100%; height: auto; }
.lesson-body h2, .lesson-body h3 { font-size: 1.1rem; margin-top: 1.75rem; }
.lesson-body img { border-radius: var(--c-radius-sm); }
/* A lesson's table — imported ones are common (a first-aid course's vital signs), and the admin
   editor can now write one. Hairlines from the palette, so it follows light and dark. */
body .lesson-body table { width: 100%; border-collapse: collapse; font-size: 0.95em; }
body .lesson-body th, body .lesson-body td { border: 1px solid var(--c-line); padding: 0.45rem 0.65rem; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
body .lesson-body td p { margin: 0; }

.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); }
/* A question's own content (a picture, a paragraph of context) and a hint on request. The
   `body` prefix is load-bearing: every theme resets `p` margins at (0,1,2). */
body .quiz-question-body { margin: 0 0 0.75rem; }
body .quiz-question-body img { max-width: 100%; height: auto; }
body .quiz-hint { margin: 0 0 0.75rem; }
body .quiz-hint summary { cursor: pointer; color: var(--c-muted); }
body .quiz-hint p { margin: 0.4rem 0 0; }

/* 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. */
/* Before you go: what a guest needs once they have booked (`events/_before_you_go.html`, drawn on
   the order and account pages from the same details the ticket email and the reminder carry).
   Spaced with `gap`, which no theme's `margin: 0` reset can reach. */
.evt-before-you-go {
  display: grid;
  gap: 0.75rem;
  margin-top: 1rem;
  padding: 1rem 1.1rem;
  border: 1px solid var(--c-line);
  border-radius: var(--c-radius);
  background: var(--c-soft);
  color: var(--c-text);
}
body .evt-before-you-go h4 { font-size: 1rem; margin: 0; }
.evt-before-you-go dl { display: grid; gap: 0.75rem; margin: 0; }
.evt-before-you-go dl > div { display: grid; gap: 0.15rem; min-width: 0; }
body .evt-before-you-go dt { font-weight: 600; }
body .evt-before-you-go dd { margin: 0; overflow-wrap: anywhere; }
.evt-before-you-go ul { margin: 0; padding-inline-start: 1.1rem; }
.evt-before-you-go dd p { margin: 0; }
.evt-before-you-go dd > ul + p { margin-top: 0.5rem; }

/* One line per fact, label beside value. It lives in the booking card, a column under 300px on
   most themes, where a grid of label-over-value tiles took three rows for three facts and was
   half the reason "Book now" sat below a laptop's fold. */
.evt-guide-facts {
  display: grid; gap: 0.35rem; margin: 0 0 1rem; padding: 0.75rem 0;
  border-top: 1px solid var(--c-line); border-bottom: 1px solid var(--c-line);
}
.evt-guide-fact {
  display: grid; grid-template-columns: minmax(0, 6.5rem) minmax(0, 1fr);
  align-items: baseline; gap: 0.75rem; 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; overflow-wrap: anywhere;
}
.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; }
/* The extras, folded (`_booking_panel.html`): a row that opens, ruled like the rows it holds. */
body .evt-book-addons-fold { min-width: 0; }
body .evt-book-addons-toggle {
  display: flex; align-items: center; gap: 0.5rem; min-height: 2.75rem; box-sizing: border-box;
  padding: 0.5rem 0; cursor: pointer; list-style: none; user-select: none;
  border-top: 1px solid var(--c-line); font-weight: 600; font-size: 0.9rem;
}
body .evt-book-addons-toggle::-webkit-details-marker { display: none; }
body .evt-book-addons-toggle::marker { content: ""; }
body .evt-book-addons-toggle:focus-visible { outline: 2px solid var(--c-text); outline-offset: 2px; }
body .evt-book-addons-count {
  min-width: 1.5rem; padding: 0 0.4rem; border-radius: 999px; text-align: center;
  background: var(--c-text); color: var(--c-bg); font-size: 0.8rem; line-height: 1.5rem;
}
body .evt-book-addons-chevron { flex: none; width: 1.15rem; height: 1.15rem; margin-inline-start: auto; transition: transform 0.15s ease; }
body .evt-book-addons-fold[open] .evt-book-addons-chevron { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) { body .evt-book-addons-chevron { transition: none; } }
.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; }

/* 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; }
/* One list for every list in the guide: highlights, what to bring, included, not included. The
   marker is the only thing that varies (a dot, a tick, a cross). */
.evt-guide-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.5rem; line-height: 1.5; }
.evt-guide-list li { position: relative; padding-left: 1.5rem; }
.evt-guide-list li::before {
  content: ""; position: absolute; left: 0; top: 0.6em;
  width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--c-accent);
}
.evt-guide-list-yes li::before, .evt-guide-list-no li::before {
  top: 0; width: auto; height: auto; border-radius: 0; background: none; font-weight: 700;
}
.evt-guide-list-yes li::before { content: "\2713"; color: var(--c-ok); }
.evt-guide-list-no li::before { content: "\2715"; color: var(--c-danger); }
.evt-guide-list-no li { color: var(--c-muted); }
/* A sub-block is a sub-heading and what it introduces. The space BETWEEN sub-blocks is a margin
   on a `div` and the space inside one is a `gap`: every theme resets `p` and `h1`-`h4` to
   `margin: 0` at (0,1,2), which is how "Fitness and health" came to sit flush on the list above
   it, closer to the wrong block than to its own text. */
.evt-guide-block { display: grid; gap: 0.6rem; align-content: start; }
.evt-guide-section > .evt-guide-block + .evt-guide-block { margin-top: 1.75rem; }
.evt-guide-blocks { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 1.75rem 1.5rem; }
body h3.evt-guide-subtitle { margin: 0; font-size: 1.05rem; font-weight: 700; line-height: 1.3; }
body p.evt-guide-text { margin: 0; line-height: 1.5; }
body p.evt-guide-text-muted { color: var(--c-muted); font-size: 0.9rem; }
.evt-guide-itinerary { margin: 0; padding: 0; list-style: none; display: grid; gap: 0; }
.evt-guide-stop { position: relative; padding: 0.4rem 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.75rem;
  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-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);
  /* `.site-search-panel` (added by `static/js/search.js` when the "What" field is enhanced)
     is `position: absolute`, anchored to whichever ancestor is positioned first — without this,
     that is the header or the body, and the panel renders somewhere that is not this form. */
  position: relative;
}
/* `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; }
/* The "What" field also carries `.site-search-input` — the selector `static/js/search.js`
   looks for (`form.querySelector(".site-search-input")`) to wire up suggestions, nothing about
   its look. Every theme uses that same class to style its own *header* search box at
   `body.theme-<name> .site-search-input` (0,2,1), which beats `.date-search-field input`
   (0,1,1) and repainted this field in the header's padding, type scale, letter-spacing, border
   and background on every theme (depot's attached-button border and studio's letter-spaced caps
   included) — a field a third of the height of its neighbours, reported against the live
   voyage shop. Restated here at equal specificity, after every theme stylesheet, on the same
   pattern as `body .site-search.site-search-page .site-search-input` above: the class is a
   hook, the ancestor decides the look. */
body .date-search-field-wide .site-search-input {
  width: 100%;
  padding: 0.55rem 0.65rem;
  font: inherit;
  letter-spacing: normal;
  text-transform: none;
  border: 1px solid var(--c-line-strong);
  border-radius: var(--c-radius-sm);
  background: var(--c-bg);
  color: var(--c-text);
}
.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; }
/* A table is bought by a party and a date, then a time (`reservations/_picker.html`). The two
   choosers sit on one row where they fit. `body ` is what lets it beat a theme's `.field` rules. */
body .reserve-choose { display: flex; flex-wrap: wrap; gap: 0.75rem 1rem; align-items: flex-end; }
body .reserve-choose .field { margin: 0; flex: 1 1 8rem; }
body .reserve-choose .reserve-date { flex: 1 1 14rem; min-width: 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; }
/* A court or a room the customer picks: its name, then the times it is free. The <fieldset>
   is the grouping a screen reader announces, so its browser border and padding go. */
body .slot-resource { border: 0; margin: 0 0 0.9rem; padding: 0; min-width: 0; }
body .slot-resource > legend { padding: 0 0 0.4rem; font-weight: 600; }
/* 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; }
/* A room is chosen by name, so its tile is wider than a time of day, and a stay says what it
   comes to under the dates. `body p` for the total: every theme resets a paragraph's margin at
   (0,1,2), which a bare `.slot-total` never beats. */
body .slot-times:has(.slot-room) { grid-template-columns: repeat(auto-fill, minmax(min(11rem, 100%), 1fr)); }
.slot-room { padding-block: 0.7rem; }
body p.slot-total { margin: 0; font-weight: 600; }
body p.slot-note { margin: 0; }
.slot-picker .empty-state { padding: 2rem 1rem; }

/* A ride (`reservations/_ride_form.html`, `_ride_quote.html`): where from, where to and when, then
   the fare. Two addresses are full-width fields and the time and the passengers share a row where
   they fit. `body ` beats a theme's `.field` and paragraph resets, which sit at (0,1,2). */
body .ride-form, body .ride-request { display: grid; gap: 0.9rem; margin: 0; }
body .ride-form .field, body .ride-request .field { margin: 0; }
body .ride-row { display: flex; flex-wrap: wrap; gap: 0.75rem 1rem; }
body .ride-row .field { flex: 1 1 9rem; }
body p.ride-note { margin: 0; }
body .ride-quote { display: grid; gap: 0.9rem; }
body .ride-quote h2 { margin: 0; }
body .ride-quote .order-meta { margin-bottom: 0; }

/* A seated show (`reservations/_seat_picker.html`): the plan is rows of real checkboxes wearing
   buttons, the way a time is a radio. The visible label is only the seat's number; a screen
   reader gets the whole sentence from the `.sr-only` span. The plan scrolls sideways inside its
   own box rather than widening the page, and every seat is a focus stop, which is what makes
   that scrolling reachable without a pointer. A taken seat is struck through as well as
   disabled — colour is never the only signal. `body ` on the rules a theme resets (paragraphs
   and lists) is what makes them apply. */
body .seat-legend, body .seat-key { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; list-style: none; margin: 0; padding: 0; }
/* The prices sat 3px above the map's own border. */
body .seat-legend + .seat-map { margin-top: 0.75rem; }
body .seat-key li { display: inline-flex; align-items: center; gap: 0.4rem; }
body .seat-section { margin: 0.5rem 0 0; font-weight: 600; font-size: 0.85rem; }
.seat-plan { display: grid; gap: 0.35rem; overflow-x: auto; padding: 0.25rem 0.25rem 0.5rem; min-width: 0; }
.seat-row { display: flex; align-items: center; gap: 0.3rem; width: max-content; min-width: 100%; }
.seat-row-label { flex: none; width: 1.75rem; font-size: 0.8rem; font-weight: 600; color: var(--c-muted); text-align: center; }
.seat {
  position: relative; flex: none; display: inline-flex; align-items: center; justify-content: center;
  box-sizing: border-box; width: 2.5rem; height: 2.5rem; padding: 0;
  border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
  background: var(--c-bg); color: var(--c-text); font-size: 0.7rem; font-weight: 600; line-height: 1;
}
label.seat { cursor: pointer; }
label.seat input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; margin: 0; }
label.seat:hover { border-color: var(--c-accent); }
label.seat:focus-within { outline: 2px solid var(--c-accent); outline-offset: 2px; }
.seat:has(input:checked) {
  background: var(--c-accent-soft); border-color: var(--c-accent);
  box-shadow: inset 0 0 0 1px var(--c-accent);
}
.seat-taken {
  cursor: not-allowed; color: var(--c-muted);
  background: linear-gradient(to top right, transparent calc(50% - 1px), currentColor calc(50% - 1px), currentColor calc(50% + 1px), transparent calc(50% + 1px)), var(--c-bg);
}
.seat-taken input { cursor: not-allowed; }
.seat-gap, .seat-off { border-color: transparent; background: transparent; visibility: hidden; }
.seat-key .seat { width: 1.1rem; height: 1.1rem; }
@media (pointer: coarse) { .seat { width: 2.75rem; height: 2.75rem; } }

/* The drawn seat map (`reservations/_seat_picker.html`, `seat_layout`). Every seat and label is
   placed by two numbers in *map units* (one seat across), and one custom property turns a unit
   into a length: `--unit`, which fits the map to the width it has, is never smaller than a seat a
   finger can press (1.75rem) nor bigger than a comfortable one (2.6rem), and past the smallest the
   map scrolls inside its own box instead of the page. `seat_map.js` sets `--unit` itself when
   somebody zooms. A seat is drawn a little smaller than its unit, so neighbours never touch.
   `100cqw` is the width of `.seat-map`, which is why that box is a size container. */
.seat-map { container-type: inline-size; min-width: 0; }
.seat-map-view {
  overflow: auto; overscroll-behavior: contain; max-height: min(70vh, 42rem);
  padding: 0.5rem; border: 1px solid var(--c-line); border-radius: var(--c-radius-sm); background: var(--c-soft);
}
/* Seats past the edge of the box fade out on that side (`seat_map.js` marks which), so a map
   wider than its box says so rather than looking finished at the edge — on a phone, a section
   of twenty seats a row is still wider than the screen. A mask, not a shadow: the seats are
   drawn over anything the box paints. */
.seat-map[data-more-right] .seat-map-view { mask-image: linear-gradient(to left, transparent, black 2.5rem); }
.seat-map[data-more-left] .seat-map-view { mask-image: linear-gradient(to right, transparent, black 2.5rem); }
.seat-map[data-more-left][data-more-right] .seat-map-view {
  mask-image: linear-gradient(to right, transparent, black 2.5rem, black calc(100% - 2.5rem), transparent);
}
.seat-canvas {
  --fit: calc((100cqw - 1rem - 2px) / var(--map-w));
  --unit: max(1.75rem, min(2.6rem, var(--fit)));
  position: relative; margin-inline: auto;
  width: calc(var(--map-w) * var(--unit)); height: calc(var(--map-h) * var(--unit));
}
.seat-canvas .seat {
  position: absolute; left: calc(var(--cx) * var(--unit)); top: calc(var(--cy) * var(--unit));
  width: calc(var(--unit) * 0.86); height: calc(var(--unit) * 0.86); translate: -50% -50%;
  border-radius: calc(var(--unit) * 0.22); font-size: calc(var(--unit) * 0.34); overflow: hidden;
}
.seat-canvas .seat-num { white-space: nowrap; }
/* A taken seat's checkbox lets the press through to the seat, so the script can say what it is. */
.seat-canvas .seat input:disabled { pointer-events: none; }
/* A seat that exists but is not sold for this show: there, so the plan is the room, but faint. */
.seat-canvas .seat-off { visibility: visible; border: 1px dashed var(--c-line-strong); background: transparent; color: var(--c-muted); opacity: 0.55; }
.seat-shape {
  position: absolute; box-sizing: border-box; display: flex; align-items: center; justify-content: center;
  left: calc(var(--sl) * var(--unit)); top: calc(var(--st) * var(--unit));
  width: calc(var(--sw) * var(--unit)); height: calc(var(--sh) * var(--unit));
  rotate: var(--sr, 0deg); color: var(--c-muted); font-size: calc(var(--unit) * 0.38); font-weight: 600;
  text-align: center; line-height: 1.1; overflow: hidden; pointer-events: none;
}
.seat-shape > span { padding: 0 0.15em; overflow-wrap: anywhere; }
.seat-shape-rect, .seat-shape-ellipse { border: 1px solid var(--c-line-strong); background: var(--c-bg); color: var(--c-text); }
.seat-shape-rect { border-radius: calc(var(--unit) * 0.15); }
.seat-shape-ellipse { border-radius: 50%; }
.seat-map-tools { display: flex; gap: 0.35rem; justify-content: flex-end; margin-bottom: 0.4rem; }
.seat-map-tools[hidden], .seat-details[hidden], .seat-total[hidden] { display: none; }
.seat-map-tools button { min-width: 2.75rem; }
.seat-details { margin: 0.5rem 0 0; padding: 0.5rem 0.75rem; border-radius: var(--c-radius-sm); background: var(--c-accent-soft); }
.seat-total { margin: 0.75rem 0 0.5rem; font-weight: 600; }
/* The pop-up a pointer gets (`seat_map.js`). Fixed, because the map scrolls inside its own box
   and a pop-up inside it would be clipped by that box; `rsPlace` keeps it inside the window. */
.seat-pop {
  position: fixed; z-index: 40; max-width: min(18rem, calc(100vw - 1rem)); padding: 0.55rem 0.75rem;
  border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm); background: var(--c-bg); color: var(--c-text);
  box-shadow: var(--c-shadow); font-size: 0.875rem; line-height: 1.35; pointer-events: none;
}
.seat-pop[hidden] { display: none; }
.seat-pop strong { display: block; }
.seat-pop span { display: block; color: var(--c-muted); }
/* A map drawn in blocks, seen whole (`seating.overview`): every block is a link to its own
   seats, drawn where it sits and turned as it is, saying what is left in it. The overview may be
   drawn smaller than a seat ever is, since nothing on it is a seat; a block is a big target. */
.seat-overview { --unit: max(0.55rem, min(2.6rem, var(--fit))); }
.seat-blocks { list-style: none; margin: 0; padding: 0; }
.seat-block-item {
  position: absolute; left: calc(var(--sl) * var(--unit)); top: calc(var(--st) * var(--unit));
  width: calc(var(--sw) * var(--unit)); height: calc(var(--sh) * var(--unit)); rotate: var(--sr, 0deg);
  /* Sized by the drawing, so the words inside are fitted to the block (below), not the window. */
  container-type: size;
}
.seat-block {
  box-sizing: border-box; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 0.3rem; padding: 0.25rem; border: 1px solid var(--c-accent); border-radius: var(--c-radius-sm);
  background: var(--c-accent-soft); color: var(--c-text); text-decoration: none; text-align: center; line-height: 1.15; overflow: hidden;
}
a.seat-block:hover, a.seat-block:focus-visible { background: color-mix(in srgb, var(--c-accent) 24%, var(--c-bg)); box-shadow: inset 0 0 0 1px var(--c-accent); }
.seat-block.is-full { border-style: dashed; border-color: var(--c-line-strong); background: var(--c-soft); color: var(--c-muted); }
.seat-block-name { font-weight: 700; font-size: 0.85rem; }
.seat-block-free { font-size: 0.75rem; }
/* On a phone an arena's blocks are 70 by 44 pixels: "Block 102" wrapped onto two lines and the
   count with it ran out over the block's border. A narrow block takes smaller words, and one too
   short for both lines says its name only; the count is still read out (it stays in the link). */
@container (max-width: 5.5rem) {
  .seat-block-name { font-size: 0.72rem; }
  .seat-block-free { font-size: 0.68rem; }
}
@container (max-height: 2.4rem) {
  .seat-block-free {
    position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap; border: 0; padding: 0;
  }
}
.seat-area-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: baseline; margin: 0 0 0.5rem; }

/* A seated show's map is drawn below the product, not in its buy column
   (`storefront/partials/_booking_below.html`, `booking_below`): in a 19rem rail a theatre scrolled
   sideways, and even where it fitted it sat off to one side of the page. Here it is one centred
   column: the date and the prices over the map, the map as wide as the page allows, and the key,
   the total and the Hold button in a narrower block under it, centred too. The buy column keeps a
   summary that links down (`reservations/_seat_summary.html`). */
.booking-below { margin-block: 2.5rem; min-width: 0; scroll-margin-top: 5rem; }
body .booking-below-title { margin: 0 0 1rem; text-align: center; }
.booking-below .seat-panel {
  container: seat-panel / inline-size;
  max-width: 60rem; margin-inline: auto; scroll-margin-top: 5rem;
}
.booking-below .seat-picker { scroll-margin-top: 5rem; }
.booking-below .seat-choose .field { max-width: 22rem; margin-inline: auto; }
body .booking-below .seat-legend, body .booking-below .seat-key { justify-content: center; }
body .booking-below .slot-lead { text-align: center; }
.booking-below .seat-form > :not(.seat-map) { width: 100%; max-width: 28rem; margin-inline: auto; box-sizing: border-box; }
.booking-below .seat-form > .seat-legend, .booking-below .seat-form > .slot-lead { max-width: none; }
.booking-below .seat-area-head { justify-content: center; }
/* Spaced by margins, not a grid gap: every theme sets `.buy-form-booking` to `display: block`,
   and resets `p` margins at (0,1,2), which `:not(:first-child)` outranks. */
body .seat-summary > :not(:first-child) { margin-top: 0.75rem; }

/* A phone is shown the sections first (`seat_sections`), and one section's seats at a time.
   Which shows is decided by the width the panel is given, not the window's, so a design with a
   narrow page still gets the list; the whole map is the fallback wherever container queries
   are not understood. */
.seat-sections { display: none; }
@container seat-panel (max-width: 36rem) {
  .has-sections .seat-sections { display: block; }
  .has-sections .seat-form { display: none; }
}
body .seat-section-list { display: grid; gap: 0.5rem; list-style: none; margin: 0.75rem 0 0; padding: 0; }
.seat-section-choice {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 1rem; min-height: 2.75rem;
  box-sizing: border-box; padding: 0.7rem 1rem; border: 1px solid var(--c-accent); border-radius: var(--c-radius-sm);
  background: var(--c-accent-soft); color: var(--c-text); text-decoration: none; line-height: 1.25;
}
a.seat-section-choice:hover, a.seat-section-choice:focus-visible { background: color-mix(in srgb, var(--c-accent) 24%, var(--c-bg)); box-shadow: inset 0 0 0 1px var(--c-accent); }
.seat-section-choice.is-full { border-style: dashed; border-color: var(--c-line-strong); background: var(--c-soft); color: var(--c-muted); }
.seat-section-name { flex: 1 1 8rem; font-weight: 700; }
.seat-section-price { font-weight: 600; }
.seat-section-free { flex-basis: 100%; font-size: 0.85rem; color: var(--c-muted); }

/* 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; }
/* The calendar is the field, and a message in its place when there is nothing to choose from. */
.rent-field { min-width: 0; }
.rent-none { margin: 0; color: var(--c-muted); }
/* 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; }
  .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); }
/* `scrollbar-gutter: stable` reserves the scrollbar's track in the content box before it is
   needed, so `.facet-count`'s `margin-left: auto` below never measures against space the
   scrollbar is about to paint over — without it, a facet with enough values to overflow 12rem
   draws its scrollbar directly on top of the count column. And `grid-template-columns: minmax(0,
   1fr)`, not the implicit `auto` track: a track with no explicit sizing grows to its content's
   min-content width, and a long value ("General admission") can't shrink below its own text —
   so every row was pinned to that width regardless of the box actually available, running the
   count straight through the reserved scrollbar gutter on any facet with a long label. */
.facet-values {
  list-style: none; margin: 0; padding: 0 0.3rem 0 0; display: grid;
  grid-template-columns: minmax(0, 1fr); gap: 0.2rem;
  max-height: 12rem; overflow-y: auto; scrollbar-gutter: stable;
}
/* The whole row is the target, not the 1rem box at the start of it. `min-width: 0` for the same
   reason as the track above: a flex item's default minimum is its content's, and without it the
   label span below can never actually reach its own `overflow: hidden; text-overflow: ellipsis`. */
.facet-value { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0; font-size: 0.9rem; cursor: pointer; min-width: 0; }
.facet-value input { flex: none; }
.facet-value > span:first-of-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.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; } }

/* Desktop: the panel is a persistent rail, not a thing to open — no chevron, no pointer, no
   click-to-collapse, because the whole point of always starting `open` is that a shopper never
   meets it closed. A phone keeps the ordinary `<details>` behaviour, since there the open panel
   is taller than the screen. 901px matches the width `depot`'s and `press`'s own rails switch
   their sticky behaviour at, so the two read as one decision about the same width. */
@media (min-width: 901px) {
  .facets-summary { cursor: default; pointer-events: none; }
  .facets-summary::after { display: none; }
}

/* --------------------------------------------------------------------------------------------
   The rail's filter (`storefront/partials/_rail_filter.html`): `depot` and `press`, the two
   designs with a rail down the side of a listing. Shared markup neither overrides, so the
   structure is here and each theme only dresses it (`body.theme-<name> .facets-sheet …`).

   Desktop: the filter is the top of the rail, every group folded to a line. Phone (900px and
   under, where both rails stop being a column): the departments and the filter are two buttons
   and the filter opens as a sheet over the page. The buttons are `<label>`s for two checkboxes,
   so nothing here waits for a script — the partial says why it cannot be a `<details>`.

   Written `body .…` throughout: a theme styles `h2`, `a` and `label` at (0,2,1), which beats a
   bare class, and these have to hold on both.
   -------------------------------------------------------------------------------------------- */
body .facets-sheet { --sheet-pad: 1rem; }
body .facets-sheet-head { display: flex; align-items: stretch; }
body .facets-sheet-title { flex: 1; margin: 0; display: flex; align-items: center; }
/* One column of lines, not the panel's grid of open groups. */
body .facets-sheet .facets { display: block; padding: 0; }
body .facet-fold { border-bottom: 1px solid var(--c-line); }
body .facet-fold > summary {
  display: flex; align-items: center; gap: 0.5rem;
  padding: 0.6rem var(--sheet-pad);
  font-size: 0.87rem; font-weight: 600; color: var(--c-text);
  cursor: pointer; list-style: none;
}
body .facet-fold > summary::-webkit-details-marker { display: none; }
/* Drawn, like the panel's own: it points right while the group is shut and down once it is open. */
body .facet-fold > summary::after {
  content: ""; flex: none; width: 0.4rem; height: 0.4rem; margin-left: auto;
  border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
  transform: rotate(-45deg); transition: transform 0.15s ease;
}
body .facet-fold[open] > summary::after { transform: rotate(45deg); }
body .facet-fold[open] > summary { color: var(--c-accent); }
body .facet-fold-count, body .rail-button-count {
  flex: none; min-width: 1.25rem; padding: 0.05rem 0.35rem; border-radius: 999px;
  font-size: 0.72rem; font-weight: 700; line-height: 1.15rem; text-align: center;
  background: var(--c-accent); color: var(--color-accent-ink, var(--c-bg));
}
body .facet-fold > .facet { padding: 0 var(--sheet-pad) 0.75rem; }
/* "In stock" is one row, not a group: it sits in the list as a line of its own. */
body .facets-sheet .facet-row { padding: 0.45rem var(--sheet-pad); border-bottom: 1px solid var(--c-line); }
body .facets-sheet .facet-actions { padding: 0.75rem var(--sheet-pad); }

@media (min-width: 901px) {
  /* Nothing to press on a desktop, so nothing to tab to either. */
  body .rail-toggle-input, body .rail-buttons, body .facets-applied, body .facets-sheet-close { display: none; }
  /* A rail is fifteen rem wide: side by side, "Show results" broke over two lines to make room
     for "Clear all". The button takes the rail's width and the link sits under it. */
  body .facets-sheet .facet-actions { flex-direction: column; align-items: stretch; gap: 0.6rem; }
  body .facets-sheet .facet-actions .text-link { align-self: center; }
}
@media (max-width: 900px) {
  /* Out of sight and still in the tab order: the checkbox is what a keyboard reaches, and its
     label is what shows the focus (below). */
  body .rail-toggle-input {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip: rect(0 0 0 0); border: 0; appearance: none;
  }
  body .rail-buttons { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 0.6rem; }
  body .rail-button {
    display: flex; align-items: center; gap: 0.5rem;
    min-height: 2.9rem; padding: 0 0.9rem;
    border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
    background: var(--c-bg); color: var(--c-text);
    font-size: 0.92rem; font-weight: 700; cursor: pointer; user-select: none;
  }
  body .rail-button-depts::after {
    content: ""; flex: none; width: 0.4rem; height: 0.4rem; margin-left: auto;
    border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
    transform: translateY(-0.12rem) rotate(45deg); transition: transform 0.15s ease;
  }
  body .rail-button-count { margin-left: auto; }
  body #rail-depts-toggle:checked ~ .rail-buttons .rail-button-depts,
  body .rail-button-filter:has(.rail-button-count) { border-color: var(--c-accent); color: var(--c-accent); }
  body #rail-depts-toggle:checked ~ .rail-buttons .rail-button-depts::after { transform: translateY(0.1rem) rotate(-135deg); }
  body #rail-depts-toggle:focus-visible ~ .rail-buttons .rail-button-depts,
  body #facets-toggle:focus-visible ~ .rail-buttons .rail-button-filter { outline: 2px solid var(--c-text); outline-offset: 2px; }

  /* The departments open in the page, under the two buttons; their own heading is the button. */
  body .rail-depts { display: none; }
  body #rail-depts-toggle:checked ~ .rail-depts { display: block; margin-top: 0.6rem; }
  body .rail-depts > h2 { display: none; }

  body .facets-applied {
    display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
    margin: 0.6rem 0 0;
  }
  body .facets-applied a:not(.text-link) {
    display: inline-flex; align-items: center; gap: 0.4rem;
    min-height: 2rem; padding: 0 0.7rem; border-radius: 999px;
    font-size: 0.82rem; font-weight: 600; text-decoration: none;
    background: var(--c-text); color: var(--c-bg);
  }

  /* The sheet: the whole screen, its list scrolling between a title that stays and a button
     that stays. z-index 90 is the cart drawer's tier, above every header and banner. */
  body .facets-sheet { display: none; }
  body #facets-toggle:checked ~ .facets-sheet {
    display: flex; flex-direction: column;
    position: fixed; inset: 0; z-index: 90;
    background: var(--c-bg); color: var(--c-text);
  }
  body .facets-sheet-head { flex: none; border-bottom: 1px solid var(--c-line-strong); background: var(--c-soft); }
  body .facets-sheet-close {
    flex: none; display: inline-flex; align-items: center; justify-content: center;
    min-width: 3rem; min-height: 3rem; font-size: 1.6rem; line-height: 1; cursor: pointer;
  }
  body #facets-toggle:focus-visible ~ .facets-sheet .facets-sheet-close { outline: 2px solid var(--c-text); outline-offset: -4px; }
  /* `align-items: stretch` is not the default here: `.facets` is a grid everywhere else and sets
     `start`, which a flex column reads as "each row as wide as its own words" — the rules under
     the groups stopped halfway across the sheet and the counts sat against their labels. */
  body .facets-sheet .facets {
    flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
    display: flex; flex-direction: column; align-items: stretch;
  }
  body .facets-sheet .facets > * { flex: none; }
  body .facet-fold > summary { min-height: 3rem; font-size: 0.95rem; }
  body .facets-sheet .facet-value { min-height: 2.6rem; font-size: 0.95rem; }
  body .facets-sheet .facet-values { max-height: none; }
  body .facets-sheet .facet-actions {
    position: sticky; bottom: 0; margin-top: auto; flex-wrap: nowrap;
    padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
    border-top: 1px solid var(--c-line-strong); background: var(--c-bg);
  }
  body .facets-sheet .facet-actions .btn { flex: 1; order: 2; }
  /* The page behind a sheet that covers it must not be the thing a thumb scrolls. */
  body:has(#facets-toggle:checked) { overflow: hidden; }
}

/* 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`), drawn by
   `_enquire_after_buy.html` straight under the buy button, in the buy box. Shared markup no theme
   overrides, so this file owns it for all twelve. It is the same panel as `.enquire` (which it
   replaces where a shop has switched enquiries off), so the two cannot look like strangers.
   Named `body .notify-…` and not a bare element: every theme resets `p` to `margin: 0` at (0,1,2),
   which beats a one-class rule. */
body .notify-me {
  margin: 0.75rem 0 1.25rem;
  padding: 1rem 1.15rem;
  border: 1px solid var(--c-line);
  border-radius: var(--c-radius);
  background: var(--c-soft);
}
body .notify-me .notify-title { margin: 0 0 0.25rem; font-weight: 600; }
body .notify-me .notify-text { 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> li.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.

   **The element in the selector is what keeps this rule reachable at all.** `.option-pill` is two
   components: the buy form's `<button>` in `.option-values`, which picks a variant, and this
   `<li>`, which is a sentence about the product. A theme writing `body.theme-<name> .option-pill`
   for the button is (0,2,1) and beats `body li.option-pill` (0,1,2), so for twelve themes every
   pill in every listing wore the buy form's padding, its `cursor: pointer` and a hover that moved
   its border, and did nothing when pressed. Nothing here could see it — the class is defined, the
   colours are a palette pair and a slightly larger pill still fits its card — and it was reported
   by somebody who tried to click one. A theme names the element on both halves;
   `tests/themes/test_theme_stylesheet_rules.py` and
   `tests/themes/test_option_pills_are_not_interactive_browser.py` are the two guards.

   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); }

/* What is left of a product, laid over its photograph (`storefront/partials/_stock_flag.html`):
   "Sold out" across the middle of a dimmed picture, "Only 3 left" as a band along its foot. Big and
   plain on purpose — a shopper scanning a listing should not need to read anything to see which
   are gone.

   The label carries its own solid ground in the status pairs `.pill-danger` / `.pill-warn` use
   (`--c-danger` on `--c-danger-bg`, `--c-warn` on `--c-warn-bg`), so its contrast is the palette's
   and not the photograph's: nothing is read off a picture. The sold-out picture is dimmed by a
   scrim and a grey-out, never by opacity on the text. `.product-media` is already positioned in
   every theme (`.product-badge` is absolute inside it). `pointer-events: none` so the link under it
   still takes the press, and `body .product-card` for the specificity a theme's own reset of
   `span`/`p` would otherwise win. The words for a screen reader are `_stock_note.html`. */
body .product-card .stock-flag {
  position: absolute; z-index: 2; pointer-events: none;
  display: flex; align-items: flex-end; justify-content: center;
  left: 0; right: 0; bottom: 0;
}
body .product-card .stock-flag-label {
  display: block; width: 100%; box-sizing: border-box; padding: 0.55rem 0.75rem;
  text-align: center; text-transform: uppercase; letter-spacing: 0.05em;
  font-size: 1rem; font-weight: 800; line-height: 1.2;
}
body .product-card .stock-flag-low .stock-flag-label { background: var(--c-warn-bg); color: var(--c-warn); }
body .product-card .stock-flag-out {
  top: 0; align-items: center;
  background: color-mix(in srgb, var(--c-bg) 55%, transparent);
}
body .product-card .stock-flag-out .stock-flag-label {
  width: auto; max-width: calc(100% - 1.5rem); padding: 0.7rem 1.4rem;
  font-size: 1.35rem; letter-spacing: 0.08em; border-radius: var(--c-radius);
  background: var(--c-danger-bg); color: var(--c-danger);
  box-shadow: 0 0 0 2px var(--c-danger);
}
/* The picture of something nobody can buy goes grey, so a row of cards reads as "these are gone". */
body .product-card .product-media:has(.stock-flag-out) img { filter: grayscale(0.85); }
@media (max-width: 40rem) {
  body .product-card .stock-flag-label { font-size: 0.85rem; padding: 0.4rem 0.5rem; }
  body .product-card .stock-flag-out .stock-flag-label { font-size: 1.05rem; padding: 0.5rem 0.9rem; }
}

/* 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;
}
/* A block that has not been revealed yet is still *laid out* at its hidden scale, and a full-bleed
   band scaled by studio's `--reveal-scale: 1.04` is 4% wider than the page: 29px past the right
   edge at 1440, 8px on a phone, scrollable sideways until the visitor has scrolled it into view.
   Nothing showed it — every sweep strips the script, and the hidden state exists only once the
   script has run. `clip`, not `hidden`: it makes no scroll container, so a sticky column inside
   `<main>` still sticks. */
.js-reveal main#main { overflow-x: clip; }
@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); }

/* **A group inside a panel is one row plus an indented list, not a second dropdown.** It only
   happens in `nav_overflow.js`'s "More" panel, which moves a whole top-level entry in, submenu
   and all. Left with the top-level rules, the group stayed `inline-flex`, so its link was only
   as wide as its word and the hover lit the word rather than the line every other row lights
   (reported on the live voyage shop: "About" in More). Its own panel stayed absolutely placed at
   `top: 100%` and opened whenever More did, since `.nav-group:hover .nav-submenu` matches every
   panel below the hovered one, so it floated over the rows under it. Static and indented is the
   phone drawer's shape, one level up, and it cannot run off the edge of the page the way a
   fly-out would.
   `visibility: inherit`, never `visible`: a child that says `visible` stays focusable and
   clickable inside a closed, transparent outer panel. (0,4,1) outranks every theme's own panel
   rule at (0,3,1), which is what has to lose here: a theme's shadow and ground on the inner
   list would draw a card inside the card. */
body .site-nav .nav-submenu .nav-group { display: grid; }
body .site-nav .nav-submenu .nav-group > .nav-group-label { padding: 0.4rem 0.6rem; }
body .site-nav .nav-submenu .nav-group .nav-submenu {
  position: static;
  max-height: none;
  overflow: visible;
  opacity: 1;
  visibility: inherit;
  transform: none;
  min-width: 0;
  padding: 0 0 0 0.9rem;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  background: transparent;
  transition: none;
}

/* **A panel in columns, folded into More, is that indented list too, and no wider than its
   words.** The rule above takes a folded panel's position, ground and shadow away and leaves its
   *width*, and a panel in columns has one (`min(44rem, 90vw)`, below, because as a drop-down it
   may not grow with its content). Inside More that made More's own panel 752px wide, hung from
   a word a quarter of the way across studio's header at 768px: 189px past the window. More is
   `visibility: hidden` until it is opened, and a hidden box still has its width, so the whole
   page scrolled sideways into nothing, on every page, with nothing on it visibly wrong. One
   column, as wide as its longest entry, which is what the phone menu makes of the same panel.
   (0,4,1) and (0,5,1), over the many-columns rules further down at (0,4,0) and (0,5,0). */
body .site-nav .nav-submenu .nav-group .nav-submenu-mega {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  width: auto;
  max-width: none;
  gap: 0;
}
body .site-nav .nav-submenu .nav-group .nav-submenu-mega > .nav-columns { display: contents; }
body .site-nav .nav-submenu .nav-group .nav-submenu-mega .nav-column { margin-bottom: 0; }
/* Its entries are rows of a list again, so they read on one line like the rows around them. */
body .site-nav .nav-submenu .nav-group .nav-submenu-mega .nav-column a { white-space: nowrap; }

/* 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;
  /* **A width, not a minimum.** It opens from its own entry (`left: auto`), so a panel sized by
     its content grew with every column — a menu filled from five top-level categories drew a
     57rem panel from an entry halfway across the header and ran 80px off the page, on every
     page. Fixed at the old minimum, extra columns wrap onto a second row inside it instead,
     which is `auto-fit`'s job. `90vw` keeps it inside a small laptop. */
  left: auto;
  width: min(44rem, 90vw);
}
.site-nav .nav-submenu-mega .nav-column { display: grid; gap: 0.1rem; align-content: start; min-width: 0; }
/* **A label wraps inside its column.** A plain drop-down is as wide as its longest entry, so
   `nowrap` there is right: the panel grows to fit. A column has a width of its own, and the same
   rule there sent "Courroies & accessoires pour palmes" straight through the gutter and over the
   next column's links (a shop whose English labels fitted, read in French). `anywhere` is for
   the one long word no space will ever break. */
.site-nav .nav-submenu-mega .nav-column a { white-space: normal; overflow-wrap: anywhere; }
/* The columns' wrapper is not a box until a panel needs it to be one (below), so every rule
   written for `.nav-submenu-mega` as the grid of columns goes on meaning what it meant. */
.site-nav .nav-submenu-mega > .nav-columns { display: contents; }

/* ---- A panel that fits the window, whatever is in it --------------------------------------
   Reported on a live shop with seventeen departments and sixty-one links in its Categories menu:
   the panel was 1,058px tall, opened 190px down a 768px laptop, and nothing could bring its
   lower half on screen — it is positioned from the header, so scrolling the page moved the
   pointer off it (and the header away with it), and once the header was pinned it did not move
   at all. A drop-down's length is the merchant's, and no stylesheet can know it. Two answers,
   and it takes both:

   **It may never be taller than the room under it, and scrolls inside itself when it is.**
   `--nav-room` is that room, measured by `static/js/nav_overflow.js` each time a panel is
   opened (where the header is depends on how far the page has scrolled). Without the script a
   wide screen still gets a cap worked out from the window; below 1101px there is none, because
   that is where a theme may be drawing the same panel as a list inside its phone drawer, and a
   list with a scrollbar of its own inside a drawer is a second bug.

   **A panel with many columns uses the width it has.** Three columns of sixty links is a tall
   panel on any screen. From the seventh column on, the panel spans most of the window and its
   columns are packed by the browser (`column-width`), which balances seventeen uneven groups
   far better than a grid's rows can: 1,058px becomes about 430. The wrapper is the multi-column
   box and the panel keeps the height and the scrollbar — a multi-column box that is given a
   maximum height grows *sideways*, so the two cannot be one element. `nav_overflow.js` still
   nudges the panel back inside the window from wherever its own entry happens to be. */
.site-nav .nav-submenu { max-height: var(--nav-room, none); overflow-y: auto; scrollbar-width: thin; }
.site-nav .nav-submenu-mega.is-wide { display: block; width: min(72rem, 94vw); }
.site-nav .nav-submenu-mega.is-wide > .nav-columns { display: block; column-width: 10.5rem; column-gap: 1.75rem; }
.site-nav .nav-submenu-mega.is-wide .nav-column { break-inside: avoid; margin-bottom: 0.9rem; }
@media (min-width: 1101px) {
  .site-nav .nav-submenu { max-height: var(--nav-room, calc(100vh - 13rem)); }
  .site-nav .nav-submenu-mega:has(.nav-column:nth-child(7)) { display: block; width: min(72rem, 94vw); }
  .site-nav .nav-submenu-mega:has(.nav-column:nth-child(7)) > .nav-columns { display: block; column-width: 10.5rem; column-gap: 1.75rem; }
  .site-nav .nav-submenu-mega:has(.nav-column:nth-child(7)) .nav-column { break-inside: avoid; margin-bottom: 0.9rem; }
}

/* ---- The menu on a phone: it scrolls, and its groups fold ----------------------------------
   Everything here is behind a class `static/js/nav_phone.js` adds, so a phone that never runs
   it has the menu it always had: every entry open, in the flow of the page.

   `.nav-scroller` is whatever the burger opens — the menu itself, or the drawer a theme wraps it
   in — given the height from its own top edge to the bottom of the window. Before this the open
   menu was simply part of the header, and a header that `headroom.js` had pinned is
   `position: fixed`: a 2,300px menu in a fixed box cannot be scrolled by anything. The page
   behind is held still (`html.nav-open`) for as long as the menu is open, so a swipe that
   reaches the end of the list does not carry on into the shop underneath. */
html.nav-open { overflow: hidden; }
.nav-scroller { max-height: var(--nav-room, none); overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }

/* A group's children are shown when somebody asks for them. The button is the script's, and is
   not drawn at all outside the phone menu: on a desktop the same groups open on hover. */
.nav-group-toggle { display: none; }
/* The label keeps the whole row and the button is laid over its end, in the same grid row. A
   label squeezed into the first column drew whatever rule or hover ground its theme gives it
   only as far as the button, so a folding row and a plain one beside it ended in different
   places. `stretch`, with the label centring its own words, so that rule sits on the row's
   bottom edge however tall the button makes the row.
   **`body` and `:is()`, to (0,4,1): the row is a grid whatever a theme says a group is.** As
   `.site-nav.nav-phone .nav-collapsible` this was (0,3,0), and the five designs with a drawer
   each say `body.theme-<name> .site-nav .nav-group { display: block }` — (0,3,1) — so on those
   five the row was a block and the button dropped onto a line of its own under its label. */
body .site-nav.nav-phone :is(.nav-group, .nav-column).nav-collapsible { display: grid; grid-template-columns: minmax(0, 1fr) 2.75rem; align-items: stretch; }
/* **The row is as wide as the menu, so the button ends it.** Five designs open their menu as a
   column of entries each as wide as its own words (`align-items: flex-start`), and the grid's
   last column is then wherever the label stops: the arrow sat against the word, at a different
   place on every row. Only a folding row is stretched; a plain link is left as its design
   drew it. */
body .site-nav.nav-phone > .nav-collapsible { align-self: stretch; }
.site-nav.nav-phone .nav-collapsible > :first-child {
  grid-column: 1 / -1; grid-row: 1;
  display: flex; align-items: center; min-height: 2.75rem; padding-inline-end: 2.75rem;
}
.site-nav.nav-phone .nav-collapsible > .nav-group-toggle {
  grid-column: 2; grid-row: 1;
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 2.75rem; min-height: 2.75rem; padding: 0;
  border: 0; border-radius: var(--c-radius-sm); background: transparent; color: inherit; cursor: pointer;
}
.site-nav.nav-phone .nav-collapsible > .nav-group-toggle:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
.site-nav.nav-phone .nav-group-toggle svg { width: 1.1rem; height: 1.1rem; transition: transform 0.15s ease; }
.site-nav.nav-phone .nav-collapsible.is-open > .nav-group-toggle svg { transform: rotate(180deg); }
.site-nav.nav-phone .nav-group.nav-collapsible > .nav-submenu { grid-column: 1 / -1; }
.site-nav.nav-phone .nav-group.nav-collapsible:not(.is-open) > .nav-submenu { display: none; }
.site-nav.nav-phone .nav-column.nav-collapsible > a:not(.nav-column-head) { grid-column: 1 / -1; }
.site-nav.nav-phone .nav-column.nav-collapsible:not(.is-open) > a:not(.nav-column-head) { display: none; }
@media (prefers-reduced-motion: reduce) {
  .site-nav.nav-phone .nav-group-toggle svg { transition: none; }
}
/* 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 picture beside a label: a brand's logo, a department's icon. Sized by the text it sits
   beside (`em`), so it is the right height in a header's menu and in a panel's smaller rows
   without a rule per theme, and capped in width so a long wordmark cannot push the label out of
   its row. `contain`, never `cover`: a logo with its edges cropped is not that logo. */
.site-nav .nav-icon {
  display: inline-block; height: 1.3em; width: auto; max-width: 4.5em;
  margin-inline-end: 0.5em; object-fit: contain; vertical-align: -0.28em; flex: none;
}
/* 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; width: auto; min-width: 0; max-width: none; padding: 0; gap: 0;
  }
  body .site-nav .nav-group .nav-submenu {
    position: static;
    max-height: none;
    overflow: visible;
    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 same shape, for a design whose phone menu is still its menu on a tablet.** counter,
   encore, linden and tally fold at 1023px, and between 721px and there the block above is not
   in force: a nested entry's panel was the desktop's hover-only card inside an open drawer.
   Each of the four carried its own copy of that block at its own breakpoint, and the copies had
   already lost two lines of it (`max-height`, `overflow`), so an unfolded panel there could get
   a scrollbar of its own inside the menu's. A design now *says* where it folds —
   `<nav class="site-nav" data-nav-fold="1023">` — and the shape is stated here, beside the one
   it must match (`tests/themes/test_theme_stylesheet_rules.py` holds the two bodies equal).
   Two blocks rather than one because a selector cannot choose its media query; the attribute
   takes these to the same (0,4,1) the copies had, and this file is linked after a theme's. */
@media (max-width: 1023px) {
  body .site-nav[data-nav-fold="1023"] .nav-group { display: block; }
  body .site-nav[data-nav-fold="1023"] .nav-group .nav-submenu-mega {
    grid-template-columns: 1fr; width: auto; min-width: 0; max-width: none; padding: 0; gap: 0;
  }
  body .site-nav[data-nav-fold="1023"] .nav-group .nav-submenu {
    position: static;
    max-height: none;
    overflow: visible;
    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 phone header in two rows --------------------------------------------------------
   The logo with the counters, then the search box ended by a "Menu" button; the menu opens
   full width directly beneath, and the language picker is inside it. It was depot's own
   (`.dp-search-row`), written after a live shop's phone header was measured at four rows and
   219px, and a shop owner then asked for every design to do the same. A design opts in:
   `phone-bar` on its `.container.header-inner`, `partials/_phone_bar_row.html` where its search
   box was, `partials/_menu_language.html` as the last thing in its `<nav>`.

   **Structure here, skin in the theme.** Every structural rule starts `body`, which puts it level
   with a theme's `body.theme-<name> .x` — (0,2,1) — and this file is linked after a theme's, so
   it wins the tie; that is the convention the menu's phone shape above already relies on. What a
   design may want to change (the button's border, its weight, the gaps) is written without
   `body`, so the theme's own rule wins that. A theme rule that out-specifies these on
   `.header-inner`, `.site-brand`, `.header-actions` or `.site-search` breaks the two rows, and
   `tests/themes/test_phone_header_and_sign_in_browser.py` is what says so.

   Outside the phone the row is not a box at all, and the menu's own picker is not drawn. */
.phone-bar-row { display: contents; }
.nav-toggle-word { white-space: nowrap; }
.site-nav > .nav-extra { display: none; }
.phone-bar-row .nav-toggle {
  gap: 0.35rem; padding: 0 0.6rem; cursor: pointer;
  border: 1px solid currentColor; border-radius: var(--c-radius-sm);
  font-size: 0.85rem; font-weight: 700;
}
.phone-bar-row .nav-toggle .icon { width: 1.25rem; height: 1.25rem; flex: none; }
/* Pressed: a tint of the button's own ink, which needs no second colour and so holds on a
   header of any ground (voyage's is one of four, or a photograph). */
.header-inner.phone-bar:has(> .nav-toggle-input:checked) .nav-toggle {
  background: color-mix(in srgb, currentColor 14%, transparent);
}
.nav-language { margin-top: 0.5rem; padding-top: 0.75rem; border-top: 1px solid var(--c-line); }
@media (max-width: 720px) {
  body .header-inner.phone-bar {
    display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center;
    gap: 0.5rem 0.6rem;
  }
  /* The brand's track takes what is left after the counters, so a long wordmark shrinks rather
     than pushing the basket off a 360px screen. `stretch`, so the brand is as wide as that
     track and a logo capped at `100%` is capped at it; `margin: 0` because an auto margin (how
     three designs push their brand to one side of a desktop row) would shrink it back. */
  body .header-inner.phone-bar > .site-brand {
    grid-column: 1; grid-row: 1; justify-self: stretch; min-width: 0; margin: 0;
  }
  /* A descendant, not a child: voyage keeps its search and its counters in one box on a
     desktop, and that box is `display: contents` here. */
  body .header-inner.phone-bar .header-actions { grid-column: 2; grid-row: 1; justify-self: end; }
  body .header-inner.phone-bar .header-switchers { display: none; }
  /* A box of its own, spanning both columns: the button is as wide as what it says and the
     search has everything else. `position: relative` because it is also what the suggestions
     are measured against — `search.js` pins its panel to the search box, which here stops a
     button's width short of the screen, and a panel pinned to the end of that box and wider
     than it started 124px off the left of the screen. Against the row it is the row's width. */
  body .header-inner.phone-bar .phone-bar-row {
    grid-column: 1 / -1; grid-row: 2; position: relative;
    display: flex; align-items: stretch; gap: 0.6rem; min-width: 0;
  }
  /* `order: 0` and no margin: every phone search is otherwise sent to the end of its header
     with a gap above it (`.site-search`, far above), which in this row is after the button
     that is meant to end it, and a step down from the button's top edge. Both stretch to the
     row, so their outlines are level top and bottom. */
  body .phone-bar-row .site-search {
    position: static; order: 0; flex: 1 1 0; align-self: stretch;
    width: auto; min-width: 0; max-width: none; margin: 0;
  }
  body .phone-bar-row .site-search-panel { inset-inline: 0; width: auto; max-width: none; }
  body .phone-bar-row .nav-toggle {
    display: inline-flex; flex: none; align-self: stretch; align-items: center;
    justify-content: center; min-height: 2.75rem;
  }
  /* Closed, the menu is not there; open, it is the third row: one column, every entry as wide
     as the screen, so a folding row's arrow and a plain link end in the same place. A design
     whose menu is not a child of the bar (depot's is its own band underneath) opens it itself. */
  body .header-inner.phone-bar > .site-nav { grid-column: 1 / -1; grid-row: 3; display: none; }
  body .header-inner.phone-bar > .nav-toggle-input:checked ~ .site-nav {
    display: flex; flex-direction: column; flex-wrap: nowrap; align-items: stretch;
  }
  /* Each entry is a row a finger can press. A folding row's label is 2.75rem tall already
     (`.nav-collapsible > :first-child`, above); a plain link beside it was as tall as its own
     words, so a menu was rows of two heights and the short ones were 20px targets. */
  body .header-inner.phone-bar > .site-nav > a { display: flex; align-items: center; min-height: 2.75rem; }
  body .site-nav > .nav-extra { display: block; }
  /* The form draws the picker's arrow at its own right-hand edge, so it has to be as wide as the
     picker and no wider: stretched across the menu, the arrow sat a screen away from the box. */
  body .site-nav .nav-language .language-switcher { display: inline-flex; }
}

/* ---------------------------------------------------------------- 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 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. 780px, not 720:
   voyage's own three-track phone header (`theme.css`) switches on at 780px, so a shop between
   721 and 780px wide was drawing the *desktop* scale inside the *phone* layout — the one width
   band where neither theme nor this file had scaled anything back. */
@media (max-width: 780px) {
  body[data-logo-size="x-large"] .site-header { --logo-scale: 1.5; }
  body[data-logo-size="xx-large"] .site-header { --logo-scale: 1.7; }
}
/* **The merchant can make the header's words bolder** (Design → `header_weight`, stamped on
   <body> as `data-header-weight`). It covers the shop's name and the top-level menu links, which
   is what "make the header text bolder" means, and nothing else in the header: the basket and
   account controls are icons, and the footer draws the same brand partial and keeps the weight
   its design gave it (the name's selector starts at `.site-header`; the menu's at `.site-header`
   or depot's `.dp-navbar`, a bar of its own under the header). `standard` has no rule on
   purpose: it is the design as drawn. `bold` is a **floor**, never a fixed weight — each theme
   states its own weights as `--hw-brand` and `--hw-link`, and `max()` only ever raises them,
   because a fixed 700 made harbour's 800 name *lighter* when somebody asked for bolder. Only
   700 is offered: most of the curated families load nothing heavier (Lora, Space Grotesk and
   IBM Plex have no 800 at all), so a heavier setting would render as the same 700 face.
   Each selector is one class deeper than the theme's own (`body.theme-<name> .site-nav > a`),
   and this file is linked after the theme's, so a theme's weight cannot win.
   `.nav-group-label` is the top-level entry with a drop-down and no link of its own, and the
   "More" group nav_overflow.js builds. */
body[data-header-weight="bold"] .site-header .site-brand { font-weight: max(var(--hw-brand, 400), 700); }
body[data-header-weight="bold"] :is(.site-header, .dp-navbar) .site-nav > a,
body[data-header-weight="bold"] :is(.site-header, .dp-navbar) .site-nav > .nav-group > a,
body[data-header-weight="bold"] :is(.site-header, .dp-navbar) .site-nav > .nav-group > .nav-group-label { font-weight: max(var(--hw-link, 400), 700); }
/* **The merchant can set the header's and the footer's own font** (Header & footer → Font, stamped
   on <body> as `data-header-font` / `data-footer-font`, with the family's stack in `--font-header` /
   `--font-footer`). Nothing is drawn when the attribute is absent: blank is the design as drawn, so
   a shop that never opens the screen is unchanged.
   One family covers a whole area, and the gate names the *chrome*: `.site-header`, depot's and
   voyage's wrappers (`.dp-chrome`, `.vy-chrome`, which also hold the strip above the header and, for
   depot, the department bar) and `.site-footer`, which holds a footer the merchant built too. The
   announcement bar sits above all of these and keeps the shop's fonts on purpose.
   A theme names its fonts two ways and both have to be taken over. Most write
   `font-family: var(--font-heading)`, so inside the area `--font-heading` and `--font-body` are
   pointed at the chosen family. Five give a face a private alias at `body` (counter `--ct-serif`,
   encore `--en-display`, linden `--ld-serif`, tally `--tl-display`, syllabus `--sy-mono`, which is
   a literal monospace stack). A custom property resolves where it is *declared*, so setting them on
   the chrome re-points every rule written against them below it — the way the derived colour
   tokens are restated on `body`. The basket drawer is a descendant of the header and is the
   shop's, not the header's, so it is handed the page's own fonts back. */
body[data-header-font] :is(.site-header, .dp-chrome, .vy-chrome) {
  --font-heading: var(--font-header); --font-body: var(--font-header);
  --ct-serif: var(--font-header); --en-display: var(--font-header); --ld-serif: var(--font-header);
  --tl-display: var(--font-header); --sy-mono: var(--font-header);
  font-family: var(--font-header);
}
body[data-footer-font] .site-footer {
  --font-heading: var(--font-footer); --font-body: var(--font-footer);
  --ct-serif: var(--font-footer); --en-display: var(--font-footer); --ld-serif: var(--font-footer);
  --tl-display: var(--font-footer); --sy-mono: var(--font-footer);
  font-family: var(--font-footer);
}
/* The page's own two fonts are captured on <body>, above the chrome that re-points them, so the
   drawer can be handed them back. If the shop has chosen neither, they are the guaranteed-invalid
   value and a theme's own literal fallback (`var(--font-body, Inter, …)`) applies. */
body:is([data-header-font], [data-footer-font]) { --page-font-heading: var(--font-heading); --page-font-body: var(--font-body); }
body[data-header-font] :is(.site-header, .dp-chrome, .vy-chrome) .cart-drawer {
  --font-heading: var(--page-font-heading); --font-body: var(--page-font-body);
  font-family: var(--page-font-body, system-ui, sans-serif);
}
/* **The footer logo is its own setting** (`footer_logo_size` / `data-footer-logo-size`), not a
   copy of the header's. It used to keep the design's drawn size regardless of what the header
   was told, on the reasoning that a logo sized to lead the page is too big for the line of small
   print under it — sound for the *default*, and it left no way for a merchant who wanted more
   weight in the footer to get it. Same variable (`--logo-scale`), scoped to `.site-footer`
   instead of `.site-header`, so every theme's own height calc responds to it without a second
   copy of that arithmetic. In a footer the brand sits in a column of known width, and at 1024px
   linden's is narrower than 15rem — so the column is the cap, a block column rather than the
   auto-sized grid track that made a percentage collapse in voyage's header. */
body[data-footer-logo-size="small"] .site-footer { --logo-scale: 0.8; }
body[data-footer-logo-size="large"] .site-footer { --logo-scale: 1.4; }
body[data-footer-logo-size="x-large"] .site-footer { --logo-scale: 1.9; }
body[data-footer-logo-size="xx-large"] .site-footer { --logo-scale: 2.5; }
body .site-footer .site-brand .brand-logo { max-width: min(calc(15rem * var(--logo-scale, 1)), 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; }
/* The name is its own element (never bare text beside the icon) so a theme with no room for it
   — voyage's phone utility strip — can visually hide just this and keep the glyph, without
   touching the link a screen reader announces. `display: inline` changes nothing here: it is
   what a `<span>` already is, and this line exists so every theme that inherits this partial
   without overriding it has a real rule to style, not the browser default by coincidence. */
body .social-link .social-name { display: inline; }

/* 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; }

/* ---------------------------------------------------------------- Page banner
   `{% page_banner %}` (`apps/core/templatetags/banner_tags.py`), first child of `<main>` on a
   page with a header image set — a built page's own, else the shop's default
   (`StoreSettings.header_image`). No overlaid text (the heading right below it says what the
   page is), so this is geometry only: full-bleed, a bounded height so a merchant's portrait
   photograph does not push the page's own content off the first screen, and the picture itself
   cropped to fill it. `object-fit: cover` needs an explicit height on its box or it has nothing
   to fill — the same reasoning `.pb-hero-image` and the carousel's slides already follow. */
.page-banner { width: 100%; height: clamp(10rem, 24vw, 18rem); overflow: hidden; }
.page-banner img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* ---------------------------------------------------------------- Contact form & enquiries
   `storefront/support/_contact_form.html` is the one contact form (`/contact/`, every Contact
   block, the "Enquire with us" dialog); `_contact_dialog.html` is it in a `<dialog>`, and
   `partials/_enquire.html` is the panel a product page offers when it is arranged on request, or
   cannot be bought right now. Shared markup no theme overrides, so this file owns it for every
   theme. Paragraph and heading margins are written `body p.x` / `body .x h2`: every theme resets
   `p` and `h1`–`h4` at (0,1,2), which a one-class rule here would lose to. */
body .field-optional { font-weight: 400; color: var(--c-muted); font-size: 0.85em; }
body p.contact-about { margin: 0 0 1rem; }
/* The chosen option, appended to the line above by Alpine. It is inline text in that line and needs
   no rule of its own; this one exists so the sweep for undefined classes reads it as owned. */
body .contact-about .contact-variant { font-weight: inherit; }
body .contact-card > h2 { margin: 0 0 1rem; }
body .contact-sent { padding: 0.25rem 0; }
body .contact-sent h3.contact-sent-title { margin: 0 0 0.5rem; font-size: 1.1rem; }
body .contact-sent p { margin: 0 0 0.75rem; }

body dialog.contact-dialog {
  width: min(34rem, calc(100vw - 2rem));
  max-height: min(90dvh, 52rem);
  padding: 0;
  border: 1px solid var(--c-line);
  border-radius: var(--c-radius);
  background: var(--c-bg);
  color: var(--c-text);
  box-shadow: var(--c-shadow);
  overflow: hidden;
}
/* A column whose body is the scroller: the dialog has a fixed ceiling, so every box between it
   and the scroller needs `min-height: 0` or the body grows to its content and the form's foot is
   cut off with nothing to scroll (the page builder's panel met this). */
body dialog.contact-dialog[open] { display: flex; flex-direction: column; }
body dialog.contact-dialog::backdrop { background: color-mix(in srgb, var(--c-text) 45%, transparent); }
body .contact-dialog-head {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: 1rem 1.25rem; border-bottom: 1px solid var(--c-line);
}
body .contact-dialog-head h2.contact-dialog-title { margin: 0; font-size: 1.15rem; }
body .contact-dialog-close {
  flex: none; width: 2.25rem; height: 2.25rem; display: inline-grid; place-items: center;
  border: 1px solid var(--c-line); border-radius: 999px; background: transparent; color: inherit;
  font-size: 1.35rem; line-height: 1; cursor: pointer;
}
body .contact-dialog-close:hover { background: var(--c-soft); }
body .contact-dialog-close:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
body .contact-dialog-body { min-height: 0; overflow-y: auto; padding: 1.25rem; }
/* ---- The page numbers under a listing -----------------------------------------------------
   `partials/_pagination.html`: Previous, the numbers, Next. Each theme lays `.pagination` out
   as a row with space between and is left to; this is only the middle, which no theme has met.
   A number is a square big enough to press, the current one is filled with the accent, and on
   a narrow screen the numbers take a row of their own above the two buttons instead of being
   squeezed between them. `body` in the selectors: themes style `ol` and `li` in their prose. */
body ol.pagination-pages {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.3rem;
  list-style: none; margin: 0; padding: 0;
}
body .pagination-pages li { margin: 0; padding: 0; }
body .pagination-pages a {
  display: inline-grid; place-items: center; min-width: 2.5rem; height: 2.5rem; padding: 0 0.5rem;
  border: 1px solid var(--c-line); border-radius: var(--c-radius-sm);
  background: var(--c-bg); color: var(--c-text);
  font-size: 0.9rem; font-weight: 600; font-variant-numeric: tabular-nums; text-decoration: none;
}
/* A page number is a control, not a link in a sentence, and it sits in an `li`: the rule near
   the top of this file that underlines links in running text matches it at (0,5,3). The same
   chain plus the list's class is the smallest thing that beats it. */
body .pagination-pages li a:not(.btn):not(.btn-outline):not(.btn-accent):not(.pill):not(.ck-btn) {
  text-decoration: none;
}
/* Previous and Next are as tall as the numbers between them. They are a theme's `.btn-sm`, a
   few pixels shorter than a number's square, and every theme centres the row. */
body .pagination > a.btn { display: inline-flex; align-items: center; min-height: 2.5rem; }
body .pagination-pages a:hover { border-color: var(--c-text); }
body .pagination-pages a:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
/* The palette's own accent beside the ink that is guaranteed on it. Not `--c-accent`: tally and
   encore point that at their ink, and `--color-accent-ink` on it is ink on ink (the calendar's
   chosen day met this). */
body .pagination-pages a[aria-current="page"] {
  background: var(--color-accent); border-color: var(--color-accent);
  color: var(--color-accent-ink);
}
body .pagination-pages .pagination-gap {
  min-width: 1.25rem; text-align: center; color: var(--c-muted); user-select: none;
}
@media (max-width: 640px) {
  .pagination { flex-wrap: wrap; }
  body ol.pagination-pages { order: -1; flex: 1 0 100%; }
}

/* ---- "Added to your basket" ----------------------------------------------------------------
   The dialog a buy button opens when the shop keeps shoppers on the page (`_basket_added.html`,
   `static/js/basket_added.js`). The contact dialog's box, smaller: it says one thing and offers
   two ways on. `body` in every selector for the reason above: themes reset `p` and `h1`–`h4`
   margins at (0,1,2), and a one-class rule would lose to them. Colours are the shared commerce
   tokens every theme re-points, so the dialog wears whichever design it opens over. */
body dialog.basket-added {
  width: min(26rem, calc(100vw - 2rem));
  max-height: calc(100dvh - 2rem);
  padding: 1.5rem 1.5rem 1.25rem;
  border: 1px solid var(--c-line);
  border-radius: var(--c-radius);
  background: var(--c-bg);
  color: var(--c-text);
  box-shadow: var(--c-shadow);
  overflow-y: auto;
}
body dialog.basket-added::backdrop { background: color-mix(in srgb, var(--c-text) 45%, transparent); }
body .basket-added-close {
  position: absolute; top: 0.75rem; inset-inline-end: 0.75rem;
  width: 2.25rem; height: 2.25rem; display: inline-grid; place-items: center;
  border: 1px solid var(--c-line); border-radius: 999px; background: transparent; color: inherit;
  font-size: 1.35rem; line-height: 1; cursor: pointer;
}
body .basket-added-close:hover { background: var(--c-soft); }
body .basket-added-close:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
body .basket-added h2#basket-added-title {
  display: flex; align-items: center; gap: 0.5rem;
  margin: 0 0 1rem; padding-inline-end: 2.75rem; font-size: 1.15rem; line-height: 1.3;
}
body .basket-added h2#basket-added-title:focus { outline: none; }
body .basket-added h2#basket-added-title .icon { flex: none; width: 1.25rem; height: 1.25rem; color: var(--c-accent); }
body .basket-added-line {
  display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: 0.9rem; align-items: center;
  padding: 0.9rem 0; border-block: 1px solid var(--c-line);
}
body .basket-added-line .cart-thumb { width: 4.5rem; height: 4.5rem; }
/* The words beside the picture: a grid cell that may be narrower than a long product name. */
body .basket-added-what { min-width: 0; }
body .basket-added-line .cart-thumb img { width: 100%; height: 100%; object-fit: contain; }
body .basket-added p.basket-added-name { margin: 0 0 0.2rem; font-weight: 700; overflow-wrap: anywhere; }
body .basket-added p.cart-line-meta { margin: 0; }
body .basket-added p.basket-added-total {
  display: flex; justify-content: space-between; gap: 1rem; align-items: baseline;
  margin: 0.9rem 0 0; font-variant-numeric: tabular-nums;
}
body .basket-added p.notice { margin: 0; }
/* Two buttons of one width, side by side, and stacked with the checkout first once they would
   be squeezed: on a phone the thumb's first target is the way to pay. */
body .basket-added-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; margin-top: 1.1rem; }
body .basket-added-actions > :only-child { grid-column: 1 / -1; }
body .basket-added-actions .btn { width: 100%; justify-content: center; text-align: center; min-height: 2.75rem; }
body .basket-added p.basket-added-more { margin: 0.9rem 0 0; text-align: center; }
@media (max-width: 440px) {
  body .basket-added-actions { grid-template-columns: 1fr; }
  body .basket-added-actions a.btn { order: -1; }
}
/* The form is still posting: the button says so and cannot be pressed twice. */
body form.buy-form.htmx-request button[type="submit"] { opacity: 0.6; pointer-events: none; }

/* The form is one column wherever it sits (a card, a Contact block's grid track, the dialog), and
   may be narrower than its content, or a long hint pushes a phone sideways. */
body form.contact-form { min-width: 0; }
body form.contact-form > .btn { margin-top: 0.25rem; }
body .place-order .whatsapp-order { margin: 0; }
body form.contact-form .contact-whatsapp { display: grid; gap: 0.35rem; justify-items: start; }
body form.contact-form .contact-whatsapp .hint { margin: 0; }
/* On a phone the dialog is a sheet across the width, inside the page's 16px gutter. */
@media (max-width: 480px) {
  body dialog.contact-dialog { width: calc(100vw - 2rem); max-height: calc(100dvh - 2rem); }
}

body .enquire {
  margin: 0 0 1.25rem;
  padding: 1rem 1.15rem;
  border: 1px solid var(--c-line);
  border-radius: var(--c-radius);
  background: var(--c-soft);
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1rem;
}
body p.enquire-text { margin: 0; flex: 1 1 14rem; min-width: 0; }
body .enquire .btn { flex: none; }
/* `.btn-enquire` is the class `_enquire.html` puts on the button (alongside `.btn`, which does
   the actual painting on every theme). Named here so the shared-template sweep can see it is
   accounted for — a theme adds its own rule only where bare `.btn` is not already the fill
   (voyage's full width, petal's and studio's outline-vs-fill split; see their theme.css). */
body .btn-enquire { }
/* Beside the buy button rather than instead of it: a line, not a panel. */
body .enquire.enquire-alongside { padding: 0; border: 0; background: none; margin-top: 0.75rem; }
/* What the customer told us beyond their message, above the conversation on their own page. */
body dl.contact-answers { display: grid; gap: 0.35rem; margin: 0 0 1.5rem; padding: 1rem 1.15rem; border: 1px solid var(--c-line); border-radius: var(--c-radius); background: var(--c-soft); }
body dl.contact-answers > div { display: grid; grid-template-columns: minmax(0, 12rem) minmax(0, 1fr); gap: 1rem; }
body dl.contact-answers dt { font-weight: 600; }
body dl.contact-answers dd { margin: 0; }
@media (max-width: 480px) { body dl.contact-answers > div { grid-template-columns: minmax(0, 1fr); gap: 0.1rem; } }

/* The refund request's "which items" list (`returns/request_refund.html`): a fieldset of
   `.field-check` rows. `body` because every theme resets `p` and `legend` spacing at (0,1,2). */
body :is(.refund-lines, .refund-payout) { border: 0; padding: 0; margin: 0 0 1rem; min-width: 0; }
body :is(.refund-lines, .refund-payout) legend { padding: 0; margin-bottom: 0.35rem; font-weight: 600; }
/* Where to send the money (`returns/_payout_fields.html`): the sentence above the two boxes is
   `p`, which every theme resets to `margin: 0`, so the gap below it is written on the box. */
body .refund-payout .payout-boxes { display: grid; gap: 0.75rem; margin-top: 0.75rem; }
body .refund-lines p { margin: 0 0 0.75rem; }
body .refund-lines .field-check { margin-bottom: 0.5rem; }

/* Event feedback: where else to review the shop, and the photographs of the date. */
body .review-link-list {
  list-style: none;
  margin: 1rem 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}
body .feedback-thanks + .review-elsewhere,
body .card + .card.review-elsewhere {
  margin-top: 1rem;
}
body .review-elsewhere h2 {
  margin: 0 0 0.25rem;
}
body .event-photos {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr));
  gap: 1rem;
}
body .event-photos img {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: var(--c-radius, 0.5rem);
}
body .event-photos p {
  margin: 0.4rem 0 0;
  color: var(--c-muted);
  font-size: 0.9rem;
}
body .past-events { margin-top: 1.5rem; }
body .past-event-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.75rem; }
body .past-event-list li {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 0.25rem 1rem;
  padding-bottom: 0.75rem;
  border-bottom: 1px solid var(--c-line);
}
body .past-event-list li:last-child { border-bottom: 0; padding-bottom: 0; }
body .past-event-actions { display: flex; flex-wrap: wrap; gap: 1rem; }


/* ------------------------------------------------------------------- product cards */
/* A card's picture is never taller than a comfortable part of the window. The height came from the
   width: a theme's `aspect-ratio` on a card that is 40rem wide (studio) or one of "2 columns" the
   merchant chose in the page builder is a picture 800px tall, so a listing was a column of
   screen-high photographs — too big to read the name under, and too long to scroll past. The
   ratio still applies on every card that fits under the cap, so a normal grid is unchanged.

   **`width: 100%` is what keeps the capped box as wide as its card in Firefox.** A box with an
   `aspect-ratio` and a `max-height` has the cap carried across the ratio to its width there
   (a square capped at 220px is 220px wide), where Chromium leaves a stretched flex or grid item
   at the width of its cell. So a capped picture sat at the left of its card with a strip of the
   card's own ground down its right: on a live depot shop, in every card wider than the cap. A
   width that is stated is not worked out from the height by anybody. */
body[class*="theme-"] .product-card .product-media { width: 100%; max-height: min(24rem, 55svh); }


/* ----------------------------------------------------------------- product display (the shop's choice)
   `Design → Product display` stamps `data-card-density` and `data-listing-columns` on <body>
   (`theme.SHARED_SETTINGS`), always present: "comfortable" and "auto" are the design as drawn and
   have no rule here, so a shop that never opens the screen is unchanged.

   Columns apply to a *listing* only — the shop page, a category, a collection, search — and never to
   the strip of related products on a product page or to a built page's own product grid, which has
   its own column count (`pb-count-N`). Every rule is (0,4,2) because each theme sets its grid at
   `body.theme-<name> .product-grid` (0,2,1) and again inside its own media queries. The number a
   phone shows is always fewer than a desktop's, whatever was chosen. */
body[data-listing-columns="2"] div.product-grid:not(.builder-page *, .product-page *) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
body[data-listing-columns="3"] div.product-grid:not(.builder-page *, .product-page *) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
body[data-listing-columns="4"] div.product-grid:not(.builder-page *, .product-page *) { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
body[data-listing-columns="5"] div.product-grid:not(.builder-page *, .product-page *) { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); }
@media (max-width: 1000px) {
  body:is([data-listing-columns="4"], [data-listing-columns="5"]) div.product-grid:not(.builder-page *, .product-page *) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 700px) {
  body:is([data-listing-columns="3"], [data-listing-columns="4"], [data-listing-columns="5"]) div.product-grid:not(.builder-page *, .product-page *) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* Density is how much room a card takes. Twelve themes draw a card twelve ways, so it is a *scale*
   of whatever the theme drew rather than sizes of its own: an absolute padding or title size is
   bigger than some themes' own (press pads 0.7rem above and nothing beside; depot's title is
   0.87rem) and "compact" then made their cards taller. `zoom` shrinks the words, the room round
   them and a row card's picture column together, and never grows anything. A tall picture is
   capped as well, never cropped to a different ratio, and a dense card drops its description line. */
body[data-card-density="compact"] .product-card { zoom: 0.92; }
body[data-card-density="compact"] .product-card .product-media { max-height: min(15rem, 40svh); }
body[data-card-density="dense"] .product-card { zoom: 0.85; }
body[data-card-density="dense"] .product-card .product-meta { display: none; }
body[data-card-density="dense"] .product-card .product-media { max-height: min(10rem, 30svh); }


/* ---------------------------------------------------------------------------- picture lightbox */
/* `static/js/lightbox.js` marks a picture it can enlarge and builds the one dialog they share, so
   none of this exists on a page the script never reached. The dialog is drawn over a black wash
   whatever the palette says — it holds somebody else's photograph, and white on black is the only
   pair that reads over all of them (the collection cards and lookbook captions keep the rule). */
.pb-zoomable { cursor: zoom-in; }
.pb-zoom-unit { cursor: zoom-in; }
.pb-zoomable:focus-visible { outline: 3px solid var(--c-primary, currentColor); outline-offset: 3px; }
html.pb-lightbox-open { overflow: hidden; }
body .pb-lightbox {
  position: fixed; inset: 0; box-sizing: border-box; width: 100%; height: 100%;
  max-width: none; max-height: none; margin: 0; padding: 0; border: 0;
  background: rgb(0 0 0 / 0.94); color: #fff;
}
body .pb-lightbox[open] { display: grid; place-items: center; }
body .pb-lightbox::backdrop { background: rgb(0 0 0 / 0.94); }
body .pb-lightbox .pb-lightbox-figure {
  margin: 0; display: grid; justify-items: center; gap: 0.75rem;
  max-width: 100%; max-height: 100%; padding: 4.25rem 1rem 3.25rem; box-sizing: border-box;
  touch-action: pan-y;
}
body .pb-lightbox .pb-lightbox-image {
  display: block; width: auto; height: auto; max-width: 100%;
  max-height: calc(100vh - 9.5rem); max-height: calc(100dvh - 9.5rem);
  object-fit: contain; border-radius: 4px; user-select: none;
}
body .pb-lightbox .pb-lightbox-caption {
  margin: 0; max-width: 46rem; text-align: center; font-size: 0.95rem; line-height: 1.4; color: #fff;
}
body .pb-lightbox .pb-lightbox-caption[hidden] { display: none; }
/* The counter spans the dialog's width and is drawn after the buttons, so on a phone, where the
   arrows sit at the foot, it lay over both and took every tap meant for them. It is only a label. */
body .pb-lightbox .pb-lightbox-count {
  position: absolute; inset-inline: 0; bottom: 0.9rem; margin: 0; text-align: center;
  font-size: 0.8rem; letter-spacing: 0.08em; color: #fff; pointer-events: none;
}
body .pb-lightbox .pb-lightbox-count[hidden] { display: none; }
body .pb-lightbox .pb-lightbox-button {
  position: absolute; display: grid; place-items: center; box-sizing: border-box;
  width: 2.75rem; height: 2.75rem; padding: 0; border-radius: 999px; cursor: pointer;
  border: 1px solid rgb(255 255 255 / 0.5); background: rgb(0 0 0 / 0.55); color: #fff;
  touch-action: manipulation; z-index: 1;
}
body .pb-lightbox .pb-lightbox-button[hidden] { display: none; }
body .pb-lightbox .pb-lightbox-button:hover { background: rgb(255 255 255 / 0.18); }
body .pb-lightbox .pb-lightbox-button:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }
body .pb-lightbox .pb-lightbox-close { top: 0.75rem; inset-inline-end: 0.75rem; }
body .pb-lightbox .pb-lightbox-prev { top: 50%; inset-inline-start: 0.75rem; transform: translateY(-50%); }
body .pb-lightbox .pb-lightbox-next { top: 50%; inset-inline-end: 0.75rem; transform: translateY(-50%); }
@media (max-width: 600px) {
  /* On a phone the arrows sit under the picture, out of its way: big enough for a thumb, and lifted
     clear of the home-indicator strip and the browser's own toolbar, where a tap is often taken by
     the system instead. The counter sits on the same line between them. */
  body .pb-lightbox .pb-lightbox-button { width: 3.5rem; height: 3.5rem; }
  body .pb-lightbox .pb-lightbox-prev, body .pb-lightbox .pb-lightbox-next {
    top: auto; bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem); transform: none;
  }
  body .pb-lightbox .pb-lightbox-prev { inset-inline-start: 1rem; }
  body .pb-lightbox .pb-lightbox-next { inset-inline-end: 1rem; }
  body .pb-lightbox .pb-lightbox-count { bottom: calc(env(safe-area-inset-bottom, 0px) + 2.1rem); }
  body .pb-lightbox .pb-lightbox-figure { padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 6rem); }
  body .pb-lightbox .pb-lightbox-image { max-height: calc(100vh - 11rem); max-height: calc(100dvh - 11rem); }
  body .pb-lightbox .pb-lightbox-close { width: 3rem; height: 3rem; }
}
@media (prefers-reduced-motion: no-preference) {
  body .pb-lightbox[open] .pb-lightbox-image { animation: pb-lightbox-in 180ms ease-out; }
}
@keyframes pb-lightbox-in { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: none; } }

/* ------------------------------------------------------------------------------- maps */
/* Leaflet numbers its own layers (panes 400, controls 800–1000) and, left alone, those numbers
   take part in the *page's* stacking, so a map scrolled under the pinned header was drawn over it.
   A map is one box: what is inside it stays inside it. */
.leaflet-container { isolation: isolate; }

/* ------------------------------------------------------------------------------- headroom */
/* The header comes back on a scroll up (`static/js/headroom.js`). Everything here is behind
   `html.js-headroom`, which the script adds itself, so a page that never ran it is unchanged.

   `!important` on the position is deliberate and is the one place it is: a theme lays its
   header out at (0,2,x) or more (`position: relative; z-index: 30`, studio's clear header
   `position: absolute`, voyage's `:has()` band at (1,4,1)), and pinning has to win over every
   one of them without this file knowing what they are.

   It is hidden by moving `top`, not by a transform: a transform makes the header the containing
   block of every `position: fixed` descendant, and the basket drawer and its backdrop are fixed
   descendants of the header on every theme. */
html.js-headroom { scroll-padding-top: var(--hr-h, 0px); }
/* **Whatever sticks to the top of the window sticks under the header, not behind it.** A theme
   pins its buy panel, its gallery or its department rail at `top: 1.5rem` — which was the top of
   the window until this header started coming back over it, and is now inside the header: on a
   product page the name and the price sat behind the menu for as long as anybody was scrolling
   up (reported on a live harbour shop).

   `--hr-shown` is the header's height **while it is on screen** and nothing otherwise, so a
   sticky box moves down exactly as the header arrives and back up as it leaves. `--hr-h` is the
   wrong number for this: it is the height for as long as the header is pinned, shown or not,
   and leaves a header-sized hole above the box all the way down a page. Every sticky rule in a
   theme adds `var(--hr-shown, 0px)` to its own offset; `tests/themes/
   test_sticky_under_header_browser.py` finds one that does not. The slide is timed with the
   header's, so the two move as one edge. */
.product-info, .product-gallery, .summary-card, .dp-rail, .pr-rail, .sy-aside, .st-product-side {
  transition: top 0.22s ease;
}
@media (prefers-reduced-motion: reduce) {
  .product-info, .product-gallery, .summary-card, .dp-rail, .pr-rail, .sy-aside, .st-product-side { transition: none; }
}
html.js-headroom .hr-spacer { display: block; }
html.js-headroom .hr-fixed {
  position: fixed !important; top: 0 !important; bottom: auto !important;
  inset-inline: 0 !important; z-index: 55 !important;
  transition: top 0.22s ease, box-shadow 0.22s ease;
}
html.js-headroom .hr-fixed:not(.hr-hidden) {
  box-shadow: 0 8px 20px -12px color-mix(in srgb, var(--c-text, currentColor) 45%, transparent);
}
html.js-headroom .hr-fixed.hr-snap { transition: none; }
html.js-headroom .hr-fixed.hr-hidden { top: calc(-1 * var(--hr-h, 0px) - 1.5rem) !important; }
/* A header that is see-through in the flow (over a photograph, or a wash) has only the page's
   own text under it once it is pinned. A theme names its own ground in `--hr-ground`. */
html.js-headroom .hr-fixed.hr-clear { background: var(--hr-ground, var(--color-surface, var(--c-bg))) !important; }
@media (prefers-reduced-motion: reduce) {
  html.js-headroom .hr-fixed { transition: none; }
}
@media print {
  html.js-headroom .hr-fixed { position: static !important; }
  html.js-headroom .hr-spacer { display: none; }
}

/* ---------------------------------------------------------------------------- buy bar
   `static/js/buy_bar.js` builds this from an inert <template> in `partials/_buy_bar.html`, so with
   the script blocked none of it exists and there is nothing here to have gone wrong. Every rule is
   behind `html.js-buy-bar`, a class the script adds itself, for `reveal.js`'s reason. The bar is
   the palette's own ground and ink (the theme restates `--c-*` on `body.theme-<name>`) and its
   button is the theme's own `.btn-buy`, which is how it stays legible on all twelve without a
   colour of its own.

   It is slid in with a transform, which is fine here (nothing fixed lives inside it), and is
   `visibility: hidden` until shown as well, so off screen it is not a stop in the tab order. */
html.js-buy-bar .buy-bar {
  /* `--bb-end` is room a theme keeps at the inline end (counter's docked order rail), and
     `--bb-lift` is the height of the cookie question when one is on screen: it is fixed to the
     same edge, so the bar stands on it. */
  position: fixed; inset: auto var(--bb-end, 0px) var(--bb-lift, 0px) 0; z-index: 40;
  background: var(--c-bg); color: var(--c-text);
  border-top: 1px solid var(--c-line-strong);
  box-shadow: 0 -10px 24px -14px color-mix(in srgb, var(--c-text) 40%, transparent);
  padding: 0.65rem max(1rem, env(safe-area-inset-right)) max(0.65rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
  transform: translateY(100%); visibility: hidden;
  transition: transform 0.22s ease, visibility 0s linear 0.22s;
}
html.js-buy-bar .buy-bar.is-shown { transform: none; visibility: visible; transition: transform 0.22s ease; }
html.js-buy-bar .buy-bar-inner {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  max-width: 76rem; margin-inline: auto;
}
html.js-buy-bar .buy-bar .buy-bar-facts {
  display: flex; flex-direction: column; min-width: 0; margin: 0; line-height: 1.25;
}
html.js-buy-bar .buy-bar-name {
  font-weight: 600; font-size: 0.95rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
html.js-buy-bar .buy-bar-price { font-size: 0.9rem; color: var(--c-text); }
html.js-buy-bar .buy-bar-price:empty { display: none; }
/* `width: auto` and `flex: none`: voyage's `.btn-buy` is full width, and the bar is one line. */
html.js-buy-bar .buy-bar .buy-bar-btn { width: auto; flex: none; white-space: nowrap; }
/* Room at the foot of the page for what is under the bar, only while it is there. */
html.has-buy-bar body { padding-bottom: var(--bb-h, 0px); }
@media (max-width: 480px) {
  html.js-buy-bar .buy-bar-name { font-size: 0.85rem; }
  html.js-buy-bar .buy-bar .buy-bar-btn { padding-inline: 1.1rem; }
}
@media (prefers-reduced-motion: reduce) {
  html.js-buy-bar .buy-bar, html.js-buy-bar .buy-bar.is-shown { transition: none; }
}
@media print { html.js-buy-bar .buy-bar { display: none; } }

/* ---------------------------------------------------------------------------------------------
   The month calendar: the way a date is chosen for an event, an appointment, a table, a stay or
   a hire (`storefront/partials/_month_calendar.html`, `core/month_grid.py`).

   A field-shaped trigger (`<details>`/`<summary>`, so it opens with no script) over an in-flow
   panel with a month in it. Open days are links; everything else is text. **An unavailable day
   is never told apart by colour alone**: it is muted *and* struck through, and a sold-out day
   says so in words a screen reader reads. Every colour is a palette role or a mix of two, so
   it follows light and dark on every theme, and a theme may re-voice the shape (radius,
   border, cell form) from `body.theme-<name> .mcal-*`.

   Every selector starts `body .mcal` so it ties with, and being linked later beats, a theme's
   own `body.theme-<name> a` reset at (0,2,1) and its `p`/`h*` margin resets at (0,1,2).
   --------------------------------------------------------------------------------------------- */
body .mcal { display: grid; gap: 0.5rem; min-width: 0; }
body .mcal-label { font-weight: 600; font-size: 0.9rem; }
body .mcal-field { min-width: 0; }
body .mcal-trigger {
  display: flex; align-items: center; gap: 0.6rem; min-height: 2.75rem; box-sizing: border-box;
  padding: 0.6rem 0.9rem; cursor: pointer; list-style: none; user-select: none;
  border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
  background: var(--c-bg); color: var(--c-text); font: inherit;
}
body .mcal-trigger::-webkit-details-marker { display: none; }
body .mcal-trigger::marker { content: ""; }
body .mcal-trigger:hover { border-color: var(--c-text); }
body .mcal-trigger:focus-visible { outline: 2px solid var(--c-text); outline-offset: 2px; }
body .mcal-field[open] > .mcal-trigger { border-color: var(--c-text); }
body .mcal-icon, body .mcal-chevron { flex: none; width: 1.15rem; height: 1.15rem; }
body .mcal-chevron { margin-inline-start: auto; transition: transform 0.15s ease; }
body .mcal-field[open] .mcal-chevron { transform: rotate(180deg); }
body .mcal-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
body .mcal-value.is-empty { color: var(--c-muted); }

body .mcal-panel {
  margin-top: 0.5rem; padding: 0.75rem; box-sizing: border-box; max-width: 24rem;
  border: 1px solid var(--c-line-strong); border-radius: var(--c-radius);
  background: var(--c-bg); color: var(--c-text); box-shadow: var(--c-shadow);
}
body .mcal-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.5rem; }
body .mcal-caption { font-weight: 700; text-align: center; flex: 1; }
body .mcal-nav {
  flex: none; display: grid; place-items: center; width: 2.75rem; height: 2.75rem;
  border-radius: var(--c-radius-sm); border: 1px solid transparent; background: transparent;
  color: var(--c-text); text-decoration: none;
}
body .mcal-nav svg { width: 1.1rem; height: 1.1rem; }
body a.mcal-nav:hover { background: var(--c-accent-soft); border-color: var(--c-line-strong); }
body a.mcal-nav:focus-visible { outline: 2px solid var(--c-text); outline-offset: 1px; }
/* Where the window ends the arrow is still drawn, so the heading does not slide sideways. */
body span.mcal-nav { color: var(--c-muted); opacity: 0.45; }

body .mcal-weekdays, body .mcal-grid {
  display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px;
}
body .mcal-wd {
  padding: 0.25rem 0; text-align: center; font-size: 0.75rem; font-weight: 600;
  letter-spacing: 0.04em; text-transform: uppercase; color: var(--c-muted);
}
body .mcal-day {
  position: relative; display: grid; place-items: center; box-sizing: border-box;
  min-height: 2.75rem; padding: 0; border: 0; border-radius: var(--c-radius-sm);
  background: transparent; color: var(--c-text); font: inherit; font-variant-numeric: tabular-nums;
  text-decoration: none; line-height: 1;
}
body .mcal-day.is-pad { color: var(--c-muted); }
body a.mcal-day.is-open { font-weight: 600; cursor: pointer; background: var(--c-soft); }
body a.mcal-day.is-open:hover { background: var(--c-accent-soft); box-shadow: inset 0 0 0 1.5px var(--c-text); }
body a.mcal-day.is-open:focus-visible { outline: 2px solid var(--c-text); outline-offset: 2px; z-index: 1; }
/* Not on offer: quiet, struck through — the strike is what tells it from an open day when the
   colours are the same to the eye — and never a link. */
body span.mcal-day.is-off { color: var(--c-muted); cursor: default; }
body span.mcal-day.is-off .mcal-num { text-decoration: line-through; text-decoration-thickness: 1px; opacity: 0.8; }
body .mcal-day.is-today { box-shadow: inset 0 0 0 1.5px var(--c-line-strong); }
body .mcal-day.is-today::after {
  content: ""; position: absolute; bottom: 0.3rem; left: 50%; width: 0.25rem; height: 0.25rem;
  margin-left: -0.125rem; border-radius: 50%; background: currentColor;
}
/* A chosen day, and a stay's or a hire's two ends: the accent *pair*, ink on its own fill. */
/* The two classes are written twice (`.mcal-day.mcal-day`) because an open day's own fill is
   `body a.mcal-day.is-open` at (0,2,2): the first version of this rule was (0,2,1), so a range's
   ends and middle were painted like any open day on every theme. */
/* The fill is the palette's own accent beside the ink that is guaranteed on it. Not `--c-accent`:
   tally and encore point that at their ink, and `--color-accent-ink` on it is ink on ink (1:1). */
body a.mcal-day.is-selected, body .mcal-day.mcal-day.is-start, body .mcal-day.mcal-day.is-end {
  background: var(--color-accent); color: var(--color-accent-ink); box-shadow: none;
}
body a.mcal-day.is-selected:hover, body a.mcal-day.is-start:hover, body a.mcal-day.is-end:hover {
  background: var(--color-accent); box-shadow: inset 0 0 0 2px var(--c-text);
}
body .mcal-day.mcal-day.is-in-range {
  background: var(--c-accent-soft); color: var(--c-text); border-radius: 0; font-weight: 600;
}
/* A night inside the chosen stay is part of it, whatever the month says about that day alone. */
body span.mcal-day.is-in-range .mcal-num { text-decoration: none; opacity: 1; }
body .mcal-day.mcal-day.is-start { border-start-end-radius: 0; border-end-end-radius: 0; }
body .mcal-day.mcal-day.is-end { border-start-start-radius: 0; border-end-start-radius: 0; }
body .mcal-hint { margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--c-muted); }
@media (max-width: 480px) {
  body .mcal-panel { max-width: none; padding: 0.5rem; }
}
@media (prefers-reduced-motion: reduce) { body .mcal-chevron { transition: none; } }

/* The date part of a repeating event's booking panel (`events/_booking_date.html`): the month
   calendar, then the chosen day's departures as the same pill radios an appointment's times use.
   A departure that cannot be taken is dashed and struck through as well as muted, so it is not
   told apart by colour alone, and it keeps `--c-text` so its words stay at the palette's own
   contrast rather than a dimmed mix nobody measured. */
body .evt-date { display: grid; gap: 1rem; min-width: 0; }
body fieldset.evt-day { border: 0; margin: 0; padding: 0; min-width: 0; }
body .slot-time-start { font-weight: 600; }
body .slot-time-note { font-size: 0.78rem; font-weight: 400; color: var(--c-text); }
body .slot-time.is-unavailable { border-style: dashed; cursor: not-allowed; }
body .slot-time.is-unavailable input { cursor: not-allowed; }
body .slot-time.is-unavailable .slot-time-start { text-decoration: line-through; }

/* The date part of an appointment's picker (`scheduling/_booking_date.html`): the month
   calendar, then the chosen day's times as the pills the picker has always used, then the
   phone and notes boxes and the Hold button, which are drawn only once a day is chosen. All of
   it is the swapped region, so each piece sits one `gap` from the next. */
body .slot-date { display: grid; gap: 1rem; min-width: 0; }
body .slot-field { display: grid; gap: 0.35rem; min-width: 0; }
body fieldset.slot-day-times { border: 0; margin: 0; padding: 0; min-width: 0; }
body .slot-field-label { font-weight: 600; font-size: 0.9rem; padding: 0; margin-bottom: 0.5rem; }
body .slot-hold { display: grid; gap: 1rem; }

/* ---- Phone number: a country and a number, side by side --------------------------------------
   `core/widgets/phone.html`. Without the script the country is a native `<select>` (themed by the
   one select rule above) beside the number box; `phone_country.js` hides that select and draws a
   button showing the flag and dial code, with a searchable panel under the field. Every rule is
   written `body …` for the reason the select rule is: a theme styles its form controls at (0,2,2).
   `min-width: 0` on the number box and the wrap on the row are what keep a 360px phone from
   scrolling sideways with a country list and a number each wanting their own width. */
/* An address asked as one question (`core/widgets/address.html`): a label over each box, the town and
   the postcode side by side where there is room. `min-width: 0` on the pair's children or a long
   town name widens the form on a phone. */
/* An address *question* (an attendee form, "My details") is a fieldset like a "choose any": the
   question is its legend and the boxes sit under their own labels. */
body fieldset.address-question { border: 0; padding: 0; margin: 0; min-width: 0; }
body fieldset.address-question > legend { padding: 0; margin-bottom: 0.4rem; font-weight: 500; }
body .address-field { display: grid; gap: 0.75rem; min-width: 0; }
body .address-field .address-row { display: grid; gap: 0.25rem; min-width: 0; }
body .address-field .address-row > label { font-weight: 500; }
body .address-field .address-pair { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(min(11rem, 100%), 1fr)); min-width: 0; }
body .address-field input,
body .address-field select { width: 100%; min-width: 0; }
body .phone-field { position: relative; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: stretch; min-width: 0; }
body .phone-field select.phone-cc { flex: 1 1 13rem; width: auto; min-width: 0; max-width: 100%; }
body .phone-field .phone-number { flex: 2 1 11rem; min-width: 0; width: auto; }body .phone-field .phone-cc-button {
  flex: none; display: inline-flex; align-items: center; gap: 0.4rem; min-height: 2.75rem;
  padding: 0.5rem 0.65rem; border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
  background: var(--c-bg); color: var(--c-text); font: inherit; cursor: pointer;
}
body .phone-field .phone-cc-button:focus-visible,
body .phone-field .phone-cc-search:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
body .phone-field .phone-cc-flag { font-size: 1.15rem; line-height: 1; }
body .phone-field .phone-cc-dial { font-variant-numeric: tabular-nums; }
body .phone-field .phone-cc-caret {
  width: 5px; height: 5px; margin-left: 0.2rem; border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor; transform: translateY(-1px) rotate(45deg);
}
body .phone-field .phone-cc-panel {
  position: absolute; z-index: 30; inset-inline-start: 0; top: calc(100% + 0.25rem);
  width: min(22rem, 100%); max-width: 100vw; background: var(--c-bg); color: var(--c-text);
  border: 1px solid var(--c-line-strong); border-radius: var(--c-radius-sm);
  box-shadow: 0 8px 24px color-mix(in srgb, var(--c-text) 18%, transparent);
}
body .phone-field .phone-cc-panel[hidden] { display: none; }
body .phone-field .phone-cc-search { width: 100%; border: 0; border-bottom: 1px solid var(--c-line-strong); border-radius: 0; margin: 0; }
body .phone-field .phone-cc-list { list-style: none; margin: 0; padding: 0.25rem 0; max-height: 14rem; overflow-y: auto; }
body .phone-field .phone-cc-list li {
  display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.75rem; min-height: 2.5rem; cursor: pointer;
}
body .phone-field .phone-cc-list li[hidden] { display: none; }
body .phone-field .phone-cc-list li.is-active { background: var(--c-soft); }
body .phone-field .phone-cc-list li[aria-selected="true"] .phone-cc-name { font-weight: 700; }
body .phone-field .phone-cc-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
body .phone-field .phone-cc-dial { color: var(--c-muted); }
body p.phone-cc-none { margin: 0; padding: 0.75rem; color: var(--c-muted); }

/* ---- Two-factor setup on the account's Security page ----------------------------------------- */
body .setup-steps { margin: 0 0 1rem; padding-left: 1.25rem; }
body .setup-steps li { margin: 0.3rem 0; }
/* A QR code is read by a camera: dark on white whatever the theme is. */
body .qr { display: inline-block; max-width: 100%; padding: 0.75rem; margin: 0 0 0.75rem; background: white; border: 1px solid var(--c-line); border-radius: var(--c-radius); line-height: 0; }
body .qr svg { width: 11rem; max-width: 100%; height: auto; }
body .setup-key { overflow-wrap: anywhere; }
body .recovery-codes { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(9.5rem, 100%), 1fr)); gap: 0.5rem; }
body .recovery-codes li { padding: 0.45rem 0.7rem; border: 1px solid var(--c-line); border-radius: var(--c-radius); }
body .recovery-codes code { font-size: 0.95rem; letter-spacing: 0.04em; }

/* ---- Client galleries (`catalog.galleries`; `storefront/gallery.html`, `galleries.html`) ----
   One gallery, the list of them, the code field and the tray, on every design. Written against
   the shared `--c-*` roles so each theme's palette carries it, and every selector starts `body`
   (or `body a.` for a link) so it ties with a theme's own `p`/`h*`/`a` resets and, linked later,
   wins: the parts of a page are spaced with `gap` on their parent, never a margin a reset eats.
   A theme re-voices it from `body.theme-<name>` (`proof` does). */
body .gallery-page { display: grid; gap: 1.5rem; padding-block: 1.5rem 3rem; min-width: 0; }
body .gallery-head { display: grid; gap: 0.35rem; min-width: 0; }
body .gallery-head h1 { margin: 0; overflow-wrap: anywhere; }
body .gallery-facts { margin: 0; color: var(--c-muted); }
body .gallery-find {
  display: grid; gap: 0.75rem; min-width: 0; padding: clamp(1.25rem, 3vw, 2rem);
  border: 1px solid var(--c-line); border-radius: var(--c-radius); background: var(--c-bg);
}
/* A locked gallery's own picture, above its code field: something of the day before the code. */
body .gallery-cover { margin: 0; min-width: 0; border-radius: var(--c-radius); overflow: hidden; background: var(--c-soft); }
body .gallery-cover img { display: block; width: 100%; aspect-ratio: 16 / 7; object-fit: cover; }
@media (max-width: 640px) { body .gallery-cover img { aspect-ratio: 4 / 3; } }
body .gallery-find-title { margin: 0; font-size: clamp(1.4rem, 2.6vw, 2rem); }
body .gallery-find-lead { margin: 0; color: var(--c-muted); max-width: 40rem; }
body .gallery-section-title { margin: 0.5rem 0 0; }
/* "Are these your photographs?" at the foot of a gallery made for a client: quieter than the
   code field, a rule above it rather than a box around it. */
body .gallery-client {
  display: grid; gap: 0.6rem; min-width: 0; max-width: 40rem;
  padding-top: 1.25rem; border-top: 1px solid var(--c-line);
}
body .gallery-client-title { margin: 0; font-size: 1.15rem; }
body .gallery-client-lead { margin: 0; color: var(--c-muted); }
/* The client's and anybody else's email boxes: a line long enough for an address, no longer. */
body .gallery-client-form { max-width: 36rem; }
/* "Every photograph at once" above an open gallery's photographs, and a pass's own page: the
   words and the button side by side, the button under them on a phone. */
body .gallery-download {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: 1rem 1.5rem; min-width: 0; padding: 1.25rem clamp(1rem, 3vw, 1.75rem);
  border: 1px solid var(--c-line); border-radius: var(--c-radius); background: var(--c-soft);
}
body .gallery-download-words { display: grid; gap: 0.35rem; min-width: 0; flex: 1 1 18rem; }
body .gallery-download-title { margin: 0; font-size: 1.2rem; }
body .gallery-download-lead { margin: 0; color: var(--c-muted); }
body .gallery-download-form { margin: 0; flex: 0 0 auto; }
body .gallery-download-parts { display: grid; gap: 0.75rem; margin: 0; padding: 0; list-style: none; }
body .gallery-download-page .gallery-download-parts { margin-block: 1.5rem 1rem; }
body .gallery-download-part {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: 0.75rem 1.25rem; min-width: 0; padding: 1rem 1.25rem;
  border: 1px solid var(--c-line); border-radius: var(--c-radius); background: var(--c-bg);
}
body .gallery-download-part-words { display: grid; gap: 0.25rem; min-width: 0; flex: 1 1 16rem; overflow-wrap: anywhere; }

/* The code field: its label above, a tall box and the button beside it, under it on a phone. */
body .gallery-code { display: grid; gap: 0.5rem; min-width: 0; margin: 0; }
body .gallery-code-label { font-weight: 700; }
body .gallery-code-row { display: flex; flex-wrap: wrap; align-items: stretch; gap: 0.75rem; min-width: 0; }
body .gallery-code-row > .gallery-code-input { flex: 1 1 14rem; width: auto; min-width: 0; min-height: 3rem; font-size: 1.1rem; }
body .gallery-code-row > .gallery-code-button { flex: 0 0 auto; min-height: 3rem; }
body p.gallery-code-error { margin: 0; }

/* A gallery each: the picture across the top, the words on the card under it. */
body .gallery-cards {
  display: grid; gap: 1.5rem; min-width: 0;
  grid-template-columns: repeat(auto-fill, minmax(min(17.5rem, 100%), 1fr));
}
body a.gallery-card {
  display: flex; flex-direction: column; min-width: 0; overflow: hidden;
  border: 1px solid var(--c-line); border-radius: var(--c-radius);
  background: var(--c-bg); color: var(--c-text); text-decoration: none;
}
body a.gallery-card:hover { border-color: var(--c-line-strong); }
body a.gallery-card:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
body .gallery-card-media {
  display: block; height: 13.75rem; flex: none;
  background: linear-gradient(150deg, var(--c-accent-soft), var(--c-soft));
}
body .gallery-card-media img { display: block; width: 100%; height: 100%; object-fit: cover; }
body .gallery-card-body { display: grid; gap: 0.25rem; justify-items: start; padding: 1.25rem 1.5rem 1.5rem; min-width: 0; }
body .gallery-card-name { font-weight: 700; font-size: 1.2rem; line-height: 1.3; overflow-wrap: anywhere; }
body a.gallery-card:hover .gallery-card-name { color: var(--c-accent); }
body .gallery-card-facts { color: var(--c-muted); }
body .gallery-badge {
  display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 0.5rem;
  padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8125rem; font-weight: 700;
  color: var(--c-text);
}
body .gallery-badge .icon { width: 0.875rem; height: 0.875rem; flex: none; }
body .gallery-badge-open { background: var(--c-accent-soft); }
body .gallery-badge-code { background: var(--c-soft); box-shadow: inset 0 0 0 1px var(--c-line); }

/* "All 214" and "Only my hearts (4)": round chips; the one you are on is filled with ink. */
body .gallery-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
body a.gallery-chip {
  display: inline-flex; align-items: center; min-height: 2.75rem; padding: 0 1.125rem;
  border: 1px solid var(--c-line-strong); border-radius: 999px; background: var(--c-bg);
  color: var(--c-text); font-size: 0.875rem; font-weight: 700; text-decoration: none; white-space: nowrap;
}
body a.gallery-chip:hover { border-color: var(--c-text); }
body a.gallery-chip[aria-current="true"] { background: var(--c-text); border-color: var(--c-text); color: var(--c-bg); }
/* The hearts chip sits at the far end of the row, as the mock-up has it; on a phone, beside "All". */
body a.gallery-chip-hearts { margin-inline-start: auto; }

/* "Show 24 more" reloads the page with the new photographs as a grid of their own, so the
   visitor lands on them; the two grids read as one wall. */
body .gallery-page > .gallery-grid + .gallery-grid { margin-top: -0.75rem; }
body p.gallery-more { display: flex; justify-content: center; margin: 0.5rem 0 0; }

/* What "Add the files to basket" could not do, with a link to each photograph to choose on. */
body .gallery-outcome {
  display: grid; gap: 0.5rem; padding: 1rem 1.25rem;
  border: 1px solid var(--c-line-strong); border-left: 3px solid var(--c-accent);
  border-radius: var(--c-radius-sm); background: var(--c-soft); color: var(--c-text);
}
body .gallery-outcome p { margin: 0; }
body .gallery-outcome-list { margin: 0; padding-left: 1.25rem; display: grid; gap: 0.15rem; }

/* The tray: pinned to the foot of the window while the gallery is on screen, and in the page at
   its end. `hidden` until something is hearted (and `hidden` loses to `display`, so it is said).
   **The cookie question is fixed to the same edge**, so while it is open the tray is not pinned:
   it stays in the page under the photographs, neither covering the question nor covered by it,
   and pins itself again the moment the question is answered. */
body .gallery-tray {
  position: sticky; bottom: 0; z-index: 30; margin-top: 1rem;
  background: var(--c-bg); color: var(--c-text);
  border-top: 1px solid var(--c-line-strong);
  box-shadow: 0 -6px 24px color-mix(in srgb, var(--c-text) 8%, transparent);
  padding: 0.875rem max(1rem, env(safe-area-inset-right)) max(0.875rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
}
body .gallery-tray[hidden] { display: none; }
body:has(.ck-banner) .gallery-tray { position: static; }
body .gallery-tray-inner {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.75rem 1.5rem;
  width: min(76rem, 100%); margin: 0 auto;
}
body .gallery-tray-picks { display: flex; align-items: center; gap: 1rem; min-width: 0; }
body .gallery-tray-thumbs { display: flex; gap: 0.375rem; flex: none; }
body .gallery-tray-thumbs img,
body .gallery-tray-blank,
body .gallery-tray-more {
  display: block; width: 3.25rem; height: 3.25rem; border-radius: 8px; object-fit: cover; background: var(--c-soft);
}
body .gallery-tray-more { display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; }
body .gallery-tray-words { display: grid; gap: 0.1rem; min-width: 0; }
body p.gallery-tray-count { margin: 0; font-weight: 800; }
body p.gallery-tray-note { margin: 0; font-size: 0.875rem; color: var(--c-muted); }
body .gallery-tray-actions { display: flex; flex-wrap: wrap; gap: 0.625rem; }
body .gallery-tray-button { min-height: 3rem; }
@media (max-width: 600px) {
  body a.gallery-chip-hearts { margin-inline-start: 0; }
  body .gallery-tray-picks { gap: 0.75rem; }
  body .gallery-tray-thumbs img, body .gallery-tray-blank, body .gallery-tray-more { width: 2.75rem; height: 2.75rem; }
  body .gallery-tray-actions, body .gallery-tray-button { width: 100%; }
}
@media print { body .gallery-tray { display: none; } }

/* "Chat with us on WhatsApp" (`partials/_whatsapp_button.html`). The floating one sits above the
   page in a corner, clear of the cookie banner's own corner and of a phone's home bar. */
body .whatsapp-chat { margin: 0.75rem 0 0; }
body .whatsapp-chat-floating {
  position: fixed;
  right: max(1rem, env(safe-area-inset-right));
  bottom: max(1rem, env(safe-area-inset-bottom));
  margin: 0;
  z-index: 40;
}
body .whatsapp-chat-floating .btn { box-shadow: var(--c-shadow); }
