/*
 * Base styles — font loading, reset, and the site-wide nav shell. Built
 * entirely from tokens.css (loaded first) — never a raw hex/px value here.
 * See DESIGN.md for the full component system as each piece gets built.
 */

/* View Transitions API — cross-fades any classic full-page navigation
   instead of the default blank-white-flash unload/reload. Ignored outright
   by browsers that don't support it (progressive enhancement, no fallback
   needed). Most in-site navigation goes through hx-boost instead (see
   base.html + htmx-config.js), which gets the same effect via
   htmx.config.globalViewTransitions — this covers whatever isn't boosted. */
@view-transition {
  navigation: auto;
}

/* The @font-face rules moved to fonts.css, loaded before this file in
   base.html. They live there because the back office needs them too, and
   admin pages never load main.css — which is why the admin had been
   rendering in the browser's default sans-serif all along. */

/* Reset — minimal, hand-written rather than a vendored library (no build
   step, no CDN dependency). */
*,
*::before,
*::after {
  box-sizing: border-box;
}

body,
h1,
h2,
h3,
h4,
p,
figure {
  margin: 0;
}

img,
picture {
  max-width: 100%;
  display: block;
}

html {
  color-scheme: light;
}

/* CSS logical properties throughout — margin-inline-start, not
   margin-left. CLAUDE.md hard rule 10: this is what makes Arabic RTL work
   later with no rewrite. */

body {
  background: var(--color-bg-primary);
  color: var(--color-text-primary);
  font-family: var(--font-body);
  font-weight: var(--font-weight-regular);
  font-size: var(--text-base);
  line-height: 1.5;
}

a {
  color: inherit;
}

a:focus-visible,
button:focus-visible,
input:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

/* Containers focused programmatically must not paint a focus ring.
   htmx-config.js moves focus onto a swapped container so a keyboard user
   keeps their place after an HTMX swap — that container gets
   tabindex="-1" and .focus(). The browser then matched :focus-visible and
   drew its default outline around the whole region, which read as a
   stray border appearing on load and vanishing on the next click. It was
   most obvious on the wishlist page, where the page fetches its own
   contents and nothing else holds focus.

   tabindex="-1" means the element cannot be reached by tabbing, only
   programmatically — so it has no keyboard-navigation ring to suppress.
   Focus itself is kept, which is the part assistive technology uses. */
[tabindex="-1"]:focus,
[tabindex="-1"]:focus-visible {
  outline: none;
}

/* Site-wide container — DESIGN.md section 4. Content never spans the full
   viewport past desktop width; below that, the inline padding alone does
   the job.

   90rem (1440px), widened from 75rem (1200px). 1200 left the product
   photography — the thing this brand is actually selling — smaller than it
   needed to be, and squeezed the PDP's buy rail to 254px of usable width,
   which is narrow enough that a price and its struck-through original
   nearly wrapped. On a 1366 laptop the page now uses the full width; on a
   1920 monitor it centres with deliberate gutters rather than stretching.

   This is a container cap, not a breakpoint. 48rem / 64rem / 75rem remain
   the site-wide @media values, kept as literals throughout (custom
   properties can't be read inside @media queries). The PDP adds one more,
   85rem, for its three-column layout only — see the .pdp rules for the
   measurements behind it. */
.site-header,
.site-main,
.site-footer {
  max-width: 90rem;
  margin-inline: auto;
}

/* Site nav — DESIGN.md section 3: wordmark centered, links split either
   side. Mobile-first: the default (no media query) rules below are the
   small-screen layout — a hamburger toggling a full-screen off-canvas
   panel, Alpine-driven. The @media block at the bottom is the only
   desktop override, restoring the always-visible inline nav. */
.site-header {
  position: relative;
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  /* space-2, down from space-3, to pay for the larger wordmark. Raising it
     to --text-xl took the header from 78px to 90px, which pushed the page
     back down after it had just been brought up. Trimming the padding
     returns the height to 74px — below where it started — so the brand
     mark gains presence and the content still sits higher than before.
     The wordmark's own line box is doing the work of separating the row
     from the content now, rather than padding around a small one. */
  padding-block: var(--space-2);
  padding-inline: var(--space-4);
}

.site-header__toggle {
  grid-column: 1;
  justify-self: start;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.3rem;
  width: 1.75rem;
  height: 1.75rem;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
}

.site-header__toggle-bar {
  display: block;
  width: 100%;
  height: 2px;
  background: var(--color-text-primary);
  transition: transform var(--duration-fast) ease-out, opacity var(--duration-fast) ease-out;
}

/* Bars animate into an X when open — Alpine stringifies the bound boolean
   onto the real aria-expanded attribute, so plain CSS can key off it
   without any JS-driven class toggling of its own. */
.site-header__toggle[aria-expanded="true"] .site-header__toggle-bar:nth-child(1) {
  transform: translateY(0.4rem) rotate(45deg);
}

.site-header__toggle[aria-expanded="true"] .site-header__toggle-bar:nth-child(2) {
  opacity: 0;
}

.site-header__toggle[aria-expanded="true"] .site-header__toggle-bar:nth-child(3) {
  transform: translateY(-0.4rem) rotate(-45deg);
}

/* Display type — Jost Light, used for the wordmark and every page heading.
   Shared here so the two properties stay in one place; each usage still
   sets its own size/tracking since those genuinely differ. */
.site-header__wordmark,
.content-page h1,
.content-page h2 {
  font-family: var(--font-display);
  font-weight: var(--font-weight-light);
  font-size: var(--text-lg);
  margin-block-start: var(--space-2);
}

.site-header__wordmark {
  grid-column: 2;
  justify-self: center;
  /* --text-xl, not --text-lg. At 20px against 14px nav links the wordmark
     led by 6px, which is not enough to read as the thing the header is
     built around — it sat in the row as one more label. The brand mark
     should be unmistakably first. */
  font-size: var(--text-xl);
  text-transform: uppercase;
  letter-spacing: var(--track-wide);
  text-decoration: none;
}

/* Mobile off-canvas panel — hidden by default via opacity/visibility, not
   display, so the CSS transition on the way in/out actually runs. Alpine
   only ever toggles the .is-open class; every visual state lives in CSS,
   per DESIGN.md's "transform/opacity transition only, no animation
   library" rule. */
.site-header__nav-wrap {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--color-bg-primary);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-0.5rem);
  transition: opacity var(--duration-panel) ease-out, transform var(--duration-panel) ease-out, visibility var(--duration-panel);
  z-index: 10;
}

.site-header__nav-wrap.is-open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.site-header__nav {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-4);
  list-style: none;
  margin: 0;
  padding: 0;
}

.site-header__nav a {
  text-decoration: none;
  font-size: var(--text-lg);
}

/* Standalone links (nav items, cart link, footer links): position already
   marks them interactive, so no underline at rest — underline appears on
   hover/focus/active only, per DESIGN.md section 3. One shared rule for
   every nav-style link site-wide, not one copy per component. */
.site-header__nav a:hover,
.site-header__nav a:focus-visible,
.site-header__nav a[aria-current="page"],
.site-footer__nav a:hover,
.site-footer__nav a:focus-visible {
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

@media (min-width: 48rem) {
  .site-header__toggle {
    display: none;
  }

  .site-header__nav-wrap {
    /* grid-row: 1 is required, not tidying. This element comes third in
       the DOM — after the wordmark (column 2) and the actions (column 3) —
       but asks for column 1. Grid's default sparse auto-placement never
       moves its cursor backwards, so by the time it reaches this item the
       cursor is past column 1 and it starts a second row instead.

       The header was therefore two rows tall: Shop/About sat 28px below
       the wordmark and the Wishlist/Cart links it is meant to align with,
       and the header measured 102px instead of 78px. That extra 24px was
       most of what made the gap under the header look wrong — the space
       was inside the header, not below it. Naming the row puts all three
       on one line, which is what DESIGN.md section 3 describes: wordmark
       centred, links split either side. */
    grid-column: 1;
    grid-row: 1;
    justify-self: start;
    position: static;
    min-height: auto;
    background: none;
    opacity: 1;
    visibility: visible;
    transform: none;
    z-index: auto;
  }

  .site-header__nav {
    flex-direction: row;
    gap: var(--space-4);
  }

  /* Caps with the quieter tracking, the same treatment DESIGN.md already
     specifies for buttons and variant labels. The nav was the last text in
     the header still on browser defaults — sentence case, no tracking —
     sitting directly beside a wordmark in letter-spaced caps, so the two
     read as different voices. Not applied on mobile, where the nav is a
     full-screen panel of larger tap targets rather than a row of small
     labels. */
  .site-header__nav a {
    font-size: var(--text-sm);
    text-transform: uppercase;
    letter-spacing: var(--track-ui);
  }

  .nav-action__label {
    text-transform: uppercase;
    letter-spacing: var(--track-ui);
  }
}

/* Asymmetric block padding, deliberately: space-4 above, space-5 below.
   The two edges are not the same situation.

   Above, this sits directly under the header, which already carries its
   own bottom padding — so anything generous here stacks on top of that.
   space-5 came to roughly 72px between the nav and the first line of
   content and the page read as starting late, with the header floating on
   its own; space-4 was still 56px. At space-3 the two paddings match, 24
   and 24, and the content sits under the header rather than adrift below
   it.

   Below, it meets the footer's top border, with no padding of its own on
   this side of the line, so the full space-5 is doing real work there and
   is left alone. */
.site-main {
  padding-inline: var(--space-4);
  padding-block: var(--space-3) var(--space-5);
}

.site-footer {
  padding-block: var(--space-4);
  padding-inline: var(--space-4);
  border-block-start: 1px solid var(--color-border);
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* flex-wrap, because five links do not fit one line on a small phone.
   Without it the row simply kept going: "Terms of Service" ran from 296px
   to 339px on a 320px screen, which widened the document and gave every
   page a horizontal scroll. The header's fixed off-canvas nav then sized
   itself to that 339px too, which made the header look like the cause
   when it was only downstream of it. */
/* Footer signup. Sits above the footer links with a rule between, so it
   reads as its own small invitation rather than another row of links.
   Quiet: no filled panel, no accent — it is the last thing on the page and
   should not shout over the product someone was just looking at. */
.subscribe {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-3);
  margin-block-end: var(--space-4);
  padding-block-end: var(--space-4);
  border-block-end: 1px solid var(--color-border-soft);
}

.subscribe__intro {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  /* Capped so the note never runs the full width of a wide footer, where
     a single long line is hard to pick up again at the end. */
  max-inline-size: 34rem;
}

.subscribe__title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--text-lg);
  color: var(--color-text-primary);
}

.subscribe__note {
  margin: 0;
  font-size: var(--text-sm);
}

.subscribe__field {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  align-items: center;
}

/* input[type="tel"] in the selector, not just `input`. The shared form
   rule selects `input[type="tel"]` (0-0-1-1), which ties with
   `.subscribe__field input` and then wins on source order — so the cap
   below silently did nothing and the field rendered 373px wide with the
   button wrapped underneath it. Matching the specificity states the
   reason; !important would hide it. DESIGN.md section 8b, rule 2.

   Narrower than the shared 28rem cap because a phone number is a known,
   short length, and a field far wider than its content reads as
   unfinished. */
.subscribe__field input[type="tel"] {
  inline-size: 13rem;
  max-width: 100%;
}

.site-footer__nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  list-style: none;
  margin: 0;
  padding: 0;
}

.site-footer__nav a {
  color: inherit;
  text-decoration: none;
}

/* Static content pages (About, Shipping & Returns, Contact) — a single
   readable-measure column, no grid/layout system needed for prose. */
.content-page {
  max-width: 42rem;
  margin-inline: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.content-page h1 {
  font-size: var(--text-xl);
}

/* Alpine's x-cloak is inert without this rule — the attribute is only
   removed once Alpine initialises, so without a matching CSS rule an
   element marked x-cloak renders normally on first paint and then vanishes.
   The product-page lightbox would flash fullscreen before Alpine booted. */
[x-cloak] {
  display: none !important;
}

/* Accessibility utility — visually hidden but present for screen readers.
   A standard, well-established pattern — DESIGN.md section 7. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Utility — DESIGN.md section 3 Utilities. Suppresses the platform
   scrollbar on a scrolling element without touching the scrolling itself:
   wheel, trackpad, touch, keyboard and programmatic scrolling all still
   work, only the bar's rendering is hidden.

   Applies to any INNER scrolling region — a thumbnail rail, a dropdown
   panel, a scrolling table. A grey platform scrollbar inside a small
   designed element reads as browser chrome dropped into the layout.

   Deliberately NOT applied to the page/window scrollbar: that one is a
   reader's only cue to how long a page is and where they are in it, and
   removing it makes a long page feel broken rather than clean.

   Three declarations because no single one covers every engine —
   scrollbar-width (Firefox, Chrome 121+), ::-webkit-scrollbar (older
   Chrome/Safari), -ms-overflow-style (legacy Edge). Kept in one utility
   so that trio is never re-typed, and never half-remembered.

   Pair it with a visible overflow cue: a partially-cut item at the edge,
   which is how "there is more this way" is normally signalled once the
   bar is gone. */
.scrollbar-hidden {
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.scrollbar-hidden::-webkit-scrollbar {
  display: none;
}

/* Skip link — DESIGN.md section 7. Off-screen until it receives keyboard
   focus (the very first tab stop on every page), then drops into view
   above everything else so a keyboard user can jump straight past the
   nav to #main-content instead of tabbing through it on every page. */
.skip-link {
  position: absolute;
  inset-block-start: -3rem;
  inset-inline-start: var(--space-2);
  z-index: 100;
  background: var(--color-bg-primary);
  color: var(--color-text-primary);
  padding-block: var(--space-1);
  padding-inline: var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  text-decoration: none;
  transition: inset-block-start var(--duration-fast) ease-out;
}

.skip-link:focus-visible {
  inset-block-start: var(--space-2);
}

/* Icons — DESIGN.md section 6. Every <symbol> in _icon_sprite.html uses
   stroke="currentColor", so an icon always takes the color of the text
   around it — never a color of its own (section 2's no-functional-color
   rule extends to icons too).

   The sizing rules moved to icons.css, loaded before this file. They live
   there because the back office includes the same sprite and never loads
   main.css: including the symbols without their sizing rendered a phone
   handset about 100px tall beside 14px text. Sprite and sizing travel
   together now. */

/* Home hero band — DESIGN.md section 4's 60/30/10 ratio, the Oat "30"
   finally used. Flat, no shadow/border, same rule as cards below — a
   section band is a color change, not a boxed-in card. */
.home-hero {
  background: var(--color-bg-secondary);
  border-radius: var(--radius-lg);
  padding-block: var(--space-6);
  padding-inline: var(--space-4);
  margin-block-end: var(--space-5);
  text-align: center;
}

.home-hero__tagline {
  font-family: var(--font-display);
  font-weight: var(--font-weight-light);
  font-size: var(--text-2xl);
  max-width: 40rem;
  margin-inline: auto;
}

/* Product filters — DESIGN.md section 3 Components > Filters. Mobile-first:
   stacked by default, one row from the same 48rem breakpoint as the nav.
   No wrapping panel/border — every pill (search input, dropdown toggles,
   chips) already carries its own border/shape, so a surrounding frame
   around already-bordered pills just double-framed the group without
   adding clarity. Removed after confirming this visually, not assumed:
   the earlier bordered-panel version existed specifically because
   *unstyled* controls (raw checkboxes, no shape of their own) read as
   unfinished without one — that reasoning no longer applies now that
   every control is a fully-styled pill in its own right. */
/* One wrapping row at every width, not a column on mobile.

   Stacking put each filter on its own full-width row: search plus five
   dropdowns came to six rows and a 314px-tall bar before the first
   product on an iPhone 13, even though the widest pill is only ~151px and
   two fit side by side comfortably. Wrapping brings it to 203px. That is the same "every product
   pushed below the fold" problem the dropdowns were introduced to solve,
   reappearing one level up — the toggles collapsed the filters, then the
   toggles themselves took the space back.

   The search keeps a row to itself (flex-basis 100%); the dropdowns wrap
   two or three to a line beneath it. */
.product-filters {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin-block-end: var(--space-4);
}

.product-filters__search {
  flex: 1 1 100%;
}

@media (min-width: 48rem) {
  /* The search stops claiming a whole row and shares the line, growing
     into whatever the dropdowns leave. */
  .product-filters__search {
    flex: 1 1 16rem;
  }
}

.product-filters__search input {
  max-width: none;
}

/* [type="search"] repeated here, not just .product-filters__search input —
   a real bug, caught by measuring the actual computed style rather than
   trusting the rule existed: .product-filters__search input and the
   shared input[type="search"] rule below both have identical (0,1,1)
   specificity, and the shared rule comes later in the file, so it was
   silently winning the cascade and this pill radius never actually
   applied (computed style showed 8px, not the pill value). Adding the
   attribute selector here raises this rule to (0,2,1), which reliably
   wins regardless of file order — not a fix that depends on staying
   below the shared rule forever. */
.product-filters__search input[type="search"] {
  /* Pill/capsule shape — see --radius-pill in tokens.css. Scoped to this
     specific input, not a change to every input[type=...] on the site
     (checkout/cart fields stay --radius-sm). */
  border-radius: var(--radius-pill);
}

/* Dropdown-per-group — Colour/Fabric each collapse behind a toggle button
   instead of always showing every chip inline. Without this, a catalogue
   with 20 colours pushed every product below the fold on mobile (7 rows
   of chips before the grid even started) and made the filter bar taller
   than the product grid on desktop — directly against this document's own
   "the photography is the focus" rule. The bar now stays a fixed height
   regardless of how many colours/fabrics exist; @click.outside lives on
   .product-filters__dropdown (the common ancestor of both the toggle and
   the panel), not the panel alone — see CLAUDE.md pitfall #3, the same
   mobile-nav bug this would otherwise repeat. */
.product-filters__dropdown {
  position: relative;
}

.product-filters__dropdown-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.3em;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
  background: transparent;
  padding-block: var(--space-1);
  padding-inline: var(--space-3);
  font-family: var(--font-body);
  font-size: var(--text-sm);
  color: var(--color-text-primary);
  cursor: pointer;
  transition: border-color var(--duration-fast) ease-out;
}

.product-filters__dropdown-toggle:hover {
  border-color: var(--cocoa);
}

.product-filters__dropdown-chevron {
  width: 0.8em;
  height: 0.8em;
  transform: rotate(90deg);
  transition: transform var(--duration-fast) ease-out;
}

.product-filters__dropdown-chevron.is-open {
  transform: rotate(-90deg);
}

/* Same hidden-by-opacity/visibility pattern as .site-header__nav-wrap
   (DESIGN.md's "transform/opacity transition only" rule) — Alpine only
   ever toggles .is-open, every visual state lives in CSS. */
/* Only what makes this the filter bar's panel; everything else is
   .dropdown-panel. Anchored to the inline-start, under the chip that opens
   it, and given a floor width so a menu of two short colours is not a
   sliver. */
.product-filters__dropdown-panel {
  inset-inline-start: 0;
  min-width: 14rem;
  max-width: min(20rem, calc(100vw - 2 * var(--space-3)));
  margin: 0;
}

/* Below 48rem the panels hang off the filter bar, not off their own
   toggle.

   Anchoring to the toggle was safe only while each filter had a full-width
   row to itself and every panel therefore started at the left margin. Once
   the toggles wrap into a row, a toggle in the right half puts its 14rem
   panel past the edge of the screen — measured at 194..413 in a 390px
   viewport, which pushed the document's scrollWidth to 413 and gave the
   whole page a horizontal scrollbar.

   Making the wrapper static hands the panel's containing block to the bar,
   so inset-inline: 0 spans it and no toggle position can push it out. It
   opens below the bar rather than directly under its toggle, which is the
   normal mobile pattern and keeps it clear of the products. */
@media (max-width: 47.99rem) {
  .product-filters {
    position: relative;
  }

  .product-filters__dropdown {
    position: static;
  }

  .product-filters__dropdown-panel {
    inset-inline: 0;
    min-width: 0;
    max-width: none;
  }
}


.product-filters__chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
}

/* Chip toggle — a real <input type="checkbox"> visually-hidden inside the
   label (kept in the DOM for keyboard/screen-reader use), the label
   itself styled as the pill. Selected state fills with Cocoa/Sand, the
   same monochrome brand-tone-as-state-signal already used for the
   primary button and active tab — not a new "functional" color per
   section 2, just the existing accent applied the way it already is
   elsewhere. */
.product-filters__chip {
  display: inline-flex;
  align-items: center;
  padding-block: var(--space-1);
  padding-inline: var(--space-3);
}

.product-filters__chip:has(input:checked) {
  background: var(--cocoa);
  border-color: var(--cocoa);
  color: var(--sand);
}

.product-filters__chip:has(input:focus-visible) {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

/* Colour swatch — colour_hex is real per-variant data, not a design
   token, so its color comes from an inline style, not a CSS variable;
   everything about its shape/border/sizing still comes from tokens. A
   1px Taupe border keeps a white/near-Sand swatch visible against the
   chip's own Sand background at rest. */
.product-filters__swatch {
  display: inline-block;
  width: 0.75em;
  height: 0.75em;
  border-radius: 50%;
  border: 1px solid var(--color-border);
  margin-inline-end: 0.35em;
  flex-shrink: 0;
}

@media (min-width: 48rem) {
  /* Sort sits rightmost, separated from the rest of the bar — the
     conventional place shoppers expect it, and a clear visual signal
     that it changes order, not which products are included. */
  .product-filters__dropdown--sort {
    margin-inline-start: auto;
  }

  /* ...and because it is flush right, its panel has to open leftward from
     that edge. Panels are anchored inset-inline-start: 0 by default, so
     this one's 14rem min-width ran off the side of the page and gave the
     whole document a horizontal scrollbar.

     It did that while closed, too: the panel hides with visibility +
     opacity (never display: none, so the transition can run), which means
     it stays laid out and keeps contributing scroll width even when
     nobody can see it. That made the symptom look unrelated to the
     dropdown — a page that scrolled sideways for no visible reason.

     Widening the container to 90rem is what surfaced this; it did not
     cause it. At 75rem the container simply ended far enough from the
     viewport edge to absorb the overhang. Anchoring to the end edge fixes
     it at every width. Scoped to this media query because the auto margin
     that puts sort on the right only applies here — below 48rem the
     dropdown sits inline near the left, where flipping it would push the
     panel off the other edge instead. */
  .product-filters__dropdown--sort .product-filters__dropdown-panel {
    inset-inline-start: auto;
    inset-inline-end: 0;
  }
}

/* Share row. Quiet by design: a small label and a line of icon buttons,
   no fills and no borrowed brand colours. It closes the info column, so it
   must read as a footnote to the product rather than a third call to
   action competing with the two in the buy box. */
.share-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin-block-start: var(--space-4);
  padding-block-start: var(--space-3);
  border-block-start: 1px solid var(--color-border-soft);
}

.share-row__label {
  font-size: var(--text-sm);
  text-transform: uppercase;
  letter-spacing: var(--track-ui);
  color: var(--color-text-muted);
}

.share-row__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-1);
}

/* 44px of target, 20px of icon. The icons are small because the row is
   quiet, but the tap area is not — anything under 44px is a miss waiting
   to happen on a phone (DESIGN.md section 7). */
.share-row__button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  inline-size: 2.75rem;
  block-size: 2.75rem;
  padding: 0;
  border: none;
  border-radius: var(--radius-pill);
  background: none;
  color: var(--color-text-muted);
  text-decoration: none;
  cursor: pointer;
  transition: color var(--duration-fast) ease-out,
    background-color var(--duration-fast) ease-out;
}

.share-row__button:hover,
.share-row__button:focus-visible {
  color: var(--color-text-primary);
  background: var(--color-surface-soft);
}

/* Wishlist page actions. Measured flush against the last row of cards
   (0px), which made "Clear wishlist" — the one destructive control on the
   page — read as belonging to the first product and easy to hit by
   accident. Same soft rule the related strip uses, so the two separations
   on the site match. */
.wishlist-actions {
  margin-block-start: var(--space-4);
  padding-block-start: var(--space-4);
  border-block-start: 1px solid var(--color-border-soft);
}

/* "You may also like". A section rule rather than a panel: the strip sits
   between the tabs and the footer with nothing to separate it from either,
   and a filled card here would compete with the product photography it is
   made of. The grid itself is .product-grid, unchanged, so suggestions
   wrap at exactly the widths the catalogue already uses. */
.related {
  margin-block-start: var(--space-5);
  padding-block-start: var(--space-4);
  border-block-start: 1px solid var(--color-border-soft);
}

.related__heading {
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  font-size: var(--text-lg);
  margin-block: 0 var(--space-4);
}

/* Product grid + cards — DESIGN.md section 3 Components > Cards. Flat by
   default, no shadow/border — the photography is the focus. */
/* The 260px floor rose with the container (from 220px). auto-fill fits as
   many tracks as will go, so a wider container on the old floor produced
   *more* cards of the same size — denser, not roomier, and the opposite of
   what widening was for. Raising the floor spends the extra width on
   bigger photographs instead: 4 cards a row at 1200px became 5 at 1440px,
   each ~270px wide rather than ~220px. The product photography is the
   focus (CLAUDE.md); the grid should grow it, not multiply it.

   min(260px, 100%) rather than a bare 260px: auto-fill does not shrink a
   track below its floor, so on a viewport narrower than the floor the
   single column would overflow its own container. The min() clamps it to
   the space actually available. */
.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
  gap: var(--space-4);
}

/* Spans the full grid width as a header row above the cards, rather than
   sitting in the grid as if it were a product. */
.product-grid__clear {
  grid-column: 1 / -1;
  margin: 0;
}

.product-card {
  background: var(--color-bg-primary);
  /* Containing block for the wishlist heart pinned to the card corner. */
  position: relative;
}

.product-card a {
  display: block;
  text-decoration: none;
}

.product-card__image-wrap {
  /* 4/5, matching .gallery__frame. Cards and the gallery are the same
     photograph at two sizes, so they must crop the same way — a card
     framed differently from the page it opens is a visible jump on every
     click. Both were briefly square together and have moved back
     together. */
  aspect-ratio: 4 / 5;
  background-color: var(--sand);
  background-size: cover;
  background-position: center;
  overflow: hidden;
  border-radius: var(--radius-lg);
}

/* Blur-up fade-in — shared with the product detail gallery below. The
   inlined placeholder (the wrapper's own background-image, set inline per
   image) is visible from first paint; the real <img> starts transparent
   and fades in over it once loaded, per PLAN.md's perceived-quality-
   polish table. */
/* <picture> must be told to fill its container.
   
   It is an inline wrapper with no intrinsic size of its own, so it
   collapses to the aspect ratio of the image inside it. That breaks the
   `height: 100%` on the <img> below — the percentage then resolves
   against the picture's own collapsed height rather than the framed box,
   and a 4/5 frame ends up holding a square image with the blur-up
   placeholder showing through underneath. Measured at an 80px band on
   product cards and 101px on the product page's main photo.

   One rule for every place a product photo is framed, rather than four
   copies: the wrapper is the same element doing the same job each time. */
.product-card__image-wrap picture,
.gallery__frame picture,
.gallery__thumb picture,
.variant-picker__photo picture,
.nav-panel__thumb picture {
  display: block;
  inline-size: 100%;
  block-size: 100%;
}

.product-card__image-wrap img,
.gallery__frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity var(--duration-content) ease-out, transform var(--duration-fast) ease-out;
}

.product-card__image-wrap img.is-loaded,
.gallery__frame img.is-loaded {
  opacity: 1;
}

.product-card a:hover .product-card__image-wrap img,
.product-card a:focus-visible .product-card__image-wrap img {
  transform: scale(1.02);
}

.product-card__title {
  padding-block-start: var(--space-2);
  font-size: var(--text-base);
  font-weight: var(--font-weight-regular);
}

.product-card__price {
  font-size: var(--text-base);
  color: var(--color-text-muted);
}

/* Colour swatches — a row of dots showing the colours a product comes in,
   the single most useful at-a-glance signal for a brand whose primary
   variant dimension is colour. Same visual language as the filter bar's
   swatches (dot, 1px Taupe border so a white/near-Sand colour stays
   visible against Sand), just laid out as a row under the price. */
.product-card__swatches {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35em;
  margin-block-start: var(--space-1);
}

.product-card__swatch {
  width: 0.85em;
  height: 0.85em;
  border-radius: 50%;
  border: 1px solid var(--color-border);
  flex-shrink: 0;
}

/* A colour with no colour_hex recorded — shown as an outline-only dot
   rather than skipped, so the swatch count still honestly reflects how
   many colours exist. */
.product-card__swatch--unknown {
  background: transparent;
}

/* line-height: 1 so this text can't make the swatch row taller than the
   dots themselves. Without it the row grew from 14px to 21px on any card
   that had a "+N", and since the row centres its contents, the dots then
   sat ~3px lower than on a card without one — two cards side by side had
   visibly misaligned swatch rows. Digits and "+" have no descenders, so
   a unit line-height clips nothing here. Extra inline start margin keeps
   it from crowding the last dot at the row's own tighter gap. */
.product-card__swatch-more {
  font-size: var(--text-sm);
  line-height: 1;
  color: var(--color-text-muted);
  margin-inline-start: 0.15em;
}

/* Stock state — DESIGN.md section 2. Plain text, no functional colour;
   muted so it reads as a quiet qualifier under the price rather than
   competing with the product name. */
.product-card__stock {
  margin-block-start: var(--space-1);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

/* Sold out — DESIGN.md section 2 specifies "reduced opacity on the product
   card". Applied to the *image* only, not the whole card: dimming the
   title/price text too would push Cocoa-on-Sand below WCAG AA, turning a
   status cue into a real contrast failure (section 1's hard rule). The
   photo carries the visual signal, the "Out of stock" text carries the
   meaning — two cues, neither of them colour, exactly as section 2 asks. */
.product-card--sold-out .product-card__image-wrap {
  opacity: 0.55;
}

/* Badges — DESIGN.md section 3 Components > Badges. No fill, structural
   Taupe border only — consistent with the no-functional-color rule. */
.badge {
  display: inline-block;
  border: 1px solid var(--color-border);
  /* Pill — same tag/chip family as the filter bar's chips, same shape
     language. At this height, --radius-sm only ever read as "softly
     rounded," never a true pill; --radius-pill (9999px) always is,
     regardless of height. */
  border-radius: var(--radius-pill);
  padding-block: 0.25rem;
  padding-inline: var(--space-2);
  font-size: var(--text-sm);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-text-primary);
}

/* Product detail. Single column by default (mobile-first); the two-column
   split only applies at desktop width — the same 48rem breakpoint as the
   nav, per DESIGN.md section 4. */
/* ==== Product detail page ============================================== */

/* Breadcrumb — separators are ::before content, not text nodes, so screen
   readers don't announce punctuation between every item. */
.breadcrumb {
  margin-block-end: var(--space-4);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.breadcrumb__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-1);
  list-style: none;
  margin: 0;
  padding: 0;
}

.breadcrumb__item + .breadcrumb__item::before {
  content: "/";
  margin-inline-end: var(--space-1);
  color: var(--color-border);
}

.breadcrumb__item a {
  text-decoration: none;
}

.breadcrumb__item a:hover,
.breadcrumb__item a:focus-visible {
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* Mobile-first single column. Two columns from 48rem (gallery | info+buy),
   three from 85rem (gallery | info | sticky buy box).

   85rem is this file's one PDP-specific breakpoint, and it is measured
   rather than picked. Three columns used to start at 75rem, the site's
   shared desktop value, but there is not room for three there: a fixed
   21rem buy box and a 5.75rem thumbnail rail come off the top, leaving
   the photo 273px wide at 1200px and 317px at 1280px, with ~200px of dead
   space under the gallery.

   The giveaway was that two columns did it better on a narrower screen —
   388px of photo at 1100px against 317px at 1280px. The three-column
   layout was actively worse than the one it replaced, right where it
   turned on, across the 1280x800 and 1366x768 laptops most people have.

   So the third column now waits until there is genuinely room for it.
   Everything else on the site keeps 48/64/75rem; this is deliberately
   scoped to .pdp, which is the only layout that has to fit three columns
   plus a fixed rail. See DESIGN.md section 4. */
.pdp {
  display: grid;
  gap: var(--space-4);
}

@media (min-width: 48rem) {
  .pdp {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: var(--space-5);
  }

  /* Buy box spans the full width beneath gallery+info at this size rather
     than stacking under the info column. Letting the gallery span two rows
     instead left a large dead void bottom-left, because the gallery is
     shorter than info+buy combined — caught in a real tablet screenshot,
     not predicted. Still true with a portrait gallery: measured at 1280px,
     the gallery is 595px against 1073px for info plus buy box.

     Centred, not left-aligned. It is capped at 26rem because a buy box as
     wide as the page would put a 1100px Add-to-cart button on screen, but
     that cap has to leave its spare width somewhere — shoved to one side
     it read as the panel having slipped out of place, with a large empty
     area beside it. Split evenly it reads as margin. This became visible
     on far more screens when the third column moved to 85rem, though the
     layout had always done it. */
  .pdp__buy {
    grid-column: 1 / -1;
    max-width: 26rem;
    margin-inline: auto;
  }

  /* Without a cap the gallery grows with the column all the way to the
     three-column breakpoint: at 1359px it would be 623px wide and 779px
     tall, taller than most windows, and then snap back to 403px one pixel
     later. Capped, the step at that boundary is 480 -> 403, which reads as
     a layout change rather than the photo lurching.

     The cap only binds above ~1100px; at 770px the column is narrower than
     it, so the small sizes this exists to protect are untouched. Reset at
     85rem, where the three-column track is already narrower than the cap
     and applying it would shrink the photo instead. */
  .pdp__gallery {
    max-inline-size: 30rem;
  }

  .pdp__tabs {
    grid-column: 1 / -1;
  }
}

@media (min-width: 85rem) {
  .pdp {
    /* Gallery gets the most room; the buy box is a fixed rail so its price
       and CTA never reflow as the viewport changes.

       21rem, up from 19rem. The rail is a fixed track, so widening the
       container alone would have handed every extra pixel to the two fr
       columns and left the buy box exactly as cramped as before — at 19rem
       its usable width was 254px, which is why the price, its
       struck-through original and the discount badge could not share a
       line. Widened deliberately alongside the container, not as a
       side effect of it.

       The gallery briefly took 1.45fr and the column gap dropped to
       space-4, both to buy width for a square frame that would otherwise
       leave a void under itself. Going back to 4/5 removes that need
       entirely — a portrait frame gains height from its own shape rather
       than from the width of its column — so the width and the air both
       return to the middle column, which had become cramped at ~400px.
       The buy box is untouched at 21rem throughout. */
    grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.1fr) 21rem;
  }

  .pdp__gallery {
    max-inline-size: none;
  }

  .pdp__buy {
    /* Its own column, spanning BOTH rows — the product row and the tabs
       row — so the sticky containing block covers the whole page rather
       than ending where the gallery does. Before this the buy action
       scrolled away as soon as someone reached the description or
       reviews, which is precisely when they are deciding whether to buy.
       align-self: start is what lets it stick rather than stretch to fill
       the spanned rows. */
    grid-column: 3;
    grid-row: 1 / span 2;
    max-width: none;
    position: sticky;
    inset-block-start: var(--space-4);
    align-self: start;
  }

  /* Second row, under the gallery and info columns only. */
  .pdp__tabs {
    grid-column: 1 / 3;
  }
}

/* Regular, not light. The product name is the page's anchor and the price
   is the number being decided on, so both step up one weight to carry the
   focus (.buy-box__price matches).

   One step, not more: only Jost 300 and 400 are vendored, and asking for
   500+ does not fail loudly — the browser synthesises a fake bold by
   smearing the 400 outlines, which looks muddy at display sizes and
   differs between platforms. 400 is a real drawn weight, and two weights
   is the brand guide (DESIGN.md section 1); a third file would buy a
   little more emphasis at the cost of the type system. */
.pdp__title {
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  font-size: var(--text-xl);
  margin-block-end: var(--space-2);
}


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

/* Rail above the image on mobile (horizontal scroll strip), beside it on
   desktop (vertical column) — one set of markup, CSS-only difference. */
.gallery {
  display: grid;
  gap: var(--space-2);
}

.gallery__rail {
  display: flex;
  gap: var(--space-1);
  overflow-x: auto;
  /* Keeps the strip from becoming a scroll trap on touch: it scrolls in one
     axis only and snaps to each thumbnail. */
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
}

.gallery__thumb {
  flex: 0 0 auto;
  width: 4rem;
  aspect-ratio: 1;
  padding: 0;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: var(--sand);
  overflow: hidden;
  cursor: pointer;
  scroll-snap-align: start;
  transition: border-color var(--duration-fast) ease-out, opacity var(--duration-fast) ease-out;
  opacity: 0.6;
}

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

.gallery__thumb:hover,
.gallery__thumb.is-active {
  opacity: 1;
  border-color: var(--cocoa);
}

.gallery__main {
  position: relative;
}

.gallery__frame {
  /* 4/5 portrait. Tried square and moved back: the images are `cover`, so
     square cropped roughly a fifth off the top and bottom of every photo,
     and on a hijab shot on a model that is exactly the drape the product
     is being sold on. Portrait is also the apparel convention for the same
     reason.

     It carries the layout too. A 4/5 frame is taller than a square at the
     same width, so the gallery column fills its row naturally instead of
     ending well above the info column — the void that square opened up,
     and that had to be paid for by taking width away from the middle
     column.

     .gallery__thumb stays square on request and is the one deliberate
     exception. A square thumb is a centre crop of the same photo showing
     slightly less top and bottom, which is a preview rather than a
     different picture — the common pattern in real stores. .product-card
     is not an exception and must stay 4/5 in step with this one, because
     a card is the image you click to get here. */
  aspect-ratio: 4 / 5;
  background-color: var(--sand);
  background-size: cover;
  background-position: center;
  overflow: hidden;
  border-radius: var(--radius-lg);
  /* The zoom transform is applied to the inner <img> by pdp.js; the frame
     clips it. Zoom is click-to-toggle, so the cursor carries the whole
     affordance: zoom-in invites the click, zoom-out says clicking again
     exits. No label needed, and nothing happens on plain hover. */
  cursor: zoom-in;
}

.gallery__frame.is-zoomed {
  cursor: zoom-out;
}

.gallery__frame--empty {
  cursor: default;
}

.gallery__expand {
  position: absolute;
  inset-block-start: var(--space-2);
  inset-inline-end: var(--space-2);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
  background: var(--color-bg-primary);
  cursor: pointer;
  transition: border-color var(--duration-fast) ease-out;
}

.gallery__expand .icon {
  margin-inline-end: 0;
}

.gallery__expand:hover {
  border-color: var(--cocoa);
}

/* The vertical thumbnail rail belongs to the three-column layout, so it
   shares that layout's 85rem breakpoint. Below it the rail is the same
   horizontal strip the phone gets.

   It used to start at 48rem, which put a 5.75rem track plus a gap — 108px
   — inside a two-column gallery. At 770px that column is 329px wide, so a
   third of the gallery went to thumbnails and the photo rendered 229x286:
   the smallest it gets anywhere on the site, on a screen far larger than a
   phone where it is comfortably bigger. Handing those 108px back takes the
   same photo to 329x411.

   The rail is worth its width at 85rem+, where the gallery column is
   511px and losing 108px still leaves a 403px photo. It is not worth it
   at 770px. */
@media (min-width: 85rem) {
  /* :has(), not a bare .gallery — the two-column grid must only apply when
     a rail actually exists. The rail is omitted for a single-image product,
     and applying the columns unconditionally made .gallery__main the first
     grid child, so it landed in the 4.75rem thumbnail track and rendered
     the main photo at 76px wide (measured on a real single-image product,
     which is how this was caught). A single-image gallery stays one column
     and the photo fills the available width. */
  .gallery:has(.gallery__rail) {
    /* 5.75rem, up from 4.75rem. Thumbnails stretch to fill this track, so
       it is what sets their size — at 76px they read as afterthoughts
       beside a 486px square, and the rail stopped 158px short of the
       image, which is most of what made the gallery look unfinished.

       Not sized to make the rail exactly as tall as the image: that works
       out at ~6.75rem for this product and would be over-fitted to its
       four photos, leaving every product with two or six images wrong
       instead. A larger thumbnail improves the proportion and closes most
       of the gap for any count. */
    grid-template-columns: 5.75rem minmax(0, 1fr);
    position: relative;
  }

  /* The main image must be placed explicitly now that the rail is taken
     out of flow below. Without this it becomes the first in-flow grid
     child and lands in the 5.75rem thumbnail track — the same bug the
     :has() guard above already exists to prevent, which rendered the
     photo at 76px wide. */
  .gallery:has(.gallery__rail) .gallery__main {
    grid-column: 2;
  }

  /* A product with one photo shows it at the same size as a product with
     eight.

     Without a rail the gallery is a single column, so the frame took the
     whole width: 511x639 against 403x504 for a product that has a rail —
     the same page rendering its main photo 27% larger for a reason that
     has nothing to do with the product. Moving between two products made
     the page visibly change scale.

     The cap is the rail's own track plus the gallery gap, so the photo
     lands at exactly the width it would have had with a rail rather than
     at a number chosen to look close. Centred, because a lone image
     left-aligned in a wider column reads as something missing beside it,
     while centred reads as deliberate. */
  .gallery:not(:has(.gallery__rail)) .gallery__frame {
    max-inline-size: calc(100% - 5.75rem - var(--space-2));
    margin-inline: auto;
  }

  .gallery__rail {
    flex-direction: column;
    /* Out of flow, pinned to the full height of the gallery — which means
       exactly the height of the main image, since that is now the only
       thing sizing the row.

       This replaces a fixed 34rem cap. The thumbnail count changes with
       the selected colour (4 photos on one, 8 on another), so any fixed
       height is wrong for some colour: 34rem left the rail 158px short of
       the photo with four thumbs and 74px past it with eight. Absolute
       positioning is what breaks the circularity — an in-flow rail sets
       the row height from its own content, so eight thumbs forced the row
       to 792px and the image grew to match instead of the reverse.
       min-height: 0 alone does not fix that, because an auto-sized row
       still takes its height from item content.

       Overflow scrolls within that height, invisibly — .scrollbar-hidden
       is already on the element. */
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    inline-size: 5.75rem;
    overflow-x: visible;
    overflow-y: auto;
    scroll-snap-type: y mandatory;
  }

  .gallery__thumb {
    width: 100%;
  }
}

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

/* Fixed overlay. Uses a Cocoa scrim rather than pure black so it still
   reads as part of this palette rather than a generic modal. */
.gallery__lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
  background: color-mix(in srgb, var(--cocoa) 92%, transparent);
}

.gallery__lightbox-stage {
  max-width: min(60rem, 100%);
  max-height: 100%;
}

.gallery__lightbox-stage img {
  max-height: 85vh;
  width: auto;
  margin-inline: auto;
  border-radius: var(--radius-lg);
}

.gallery__lightbox-close,
.gallery__lightbox-nav {
  position: absolute;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  border: 1px solid var(--sand);
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--sand);
  cursor: pointer;
}

.gallery__lightbox-close .icon,
.gallery__lightbox-nav .icon {
  margin-inline-end: 0;
}

.gallery__lightbox-close {
  inset-block-start: var(--space-4);
  inset-inline-end: var(--space-4);
}

.gallery__lightbox-nav--prev {
  inset-inline-start: var(--space-3);
}

.gallery__lightbox-nav--next {
  inset-inline-end: var(--space-3);
}

.gallery__lightbox-count {
  position: absolute;
  inset-block-end: var(--space-4);
  color: var(--sand);
  font-size: var(--text-sm);
}

/* ---- Variant picker -------------------------------------------------- */

.variant-picker {
  border: none;
  padding: 0;
  margin: 0 0 var(--space-3);
}

.variant-picker__legend {
  padding: 0;
  margin-block-end: var(--space-2);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.variant-picker__selected {
  text-transform: none;
  letter-spacing: normal;
  color: var(--color-text-primary);
}

.variant-picker__options {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
}

/* Same hidden-real-input pattern as the filter chips: the <input> stays in
   the DOM for keyboard/screen-reader use, the <label> carries the visuals.
   Laid out as a horizontal pill (preview then name) rather than a stacked
   tile — the stacked version measured 50x77px per colour, which on a
   product with 8 colours dominated the info column. This is the same shape
   language as the filter bar's chips, so the two read as one system. */
.variant-picker__option {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding-block: 0.3rem;
  padding-inline: var(--space-2);
  line-height: 1.2;
}

.variant-picker__option.is-selected {
  border-color: var(--cocoa);
  box-shadow: inset 0 0 0 1px var(--cocoa);
}

/* Sold-out options stay selectable (the WhatsApp enquiry still applies), so
   this is a de-emphasis, never a block. Reduced opacity plus the "Out of
   stock" text in the buy box — two cues, not colour alone. */
.variant-picker__option.is-sold-out {
  opacity: 0.5;
}

/* "This combination was never made" — a different state from sold out, and
   shown differently on purpose: a diagonal strike plus a stronger fade,
   and genuinely not clickable. There is nothing to sell here and nothing
   to enquire about, unlike a sold-out combination which may return.
   The strike is a gradient rather than text-decoration so it crosses the
   whole chip (swatch included), not just the label. */
.variant-picker__option.is-unavailable {
  opacity: 0.35;
  cursor: not-allowed;
  position: relative;
  overflow: hidden;
}

.variant-picker__option.is-unavailable::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to bottom right,
    transparent calc(50% - 0.5px),
    var(--color-text-primary) calc(50% - 0.5px),
    var(--color-text-primary) calc(50% + 0.5px),
    transparent calc(50% + 0.5px)
  );
}

.variant-picker__option.is-unavailable:hover {
  border-color: var(--color-border);
}

/* Two fieldsets stack (Fabric above Colour), so they need separating. */
.variant-picker + .variant-picker {
  margin-block-start: var(--space-3);
}

.variant-picker__option:has(input:focus-visible) {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

/* Photo and hex previews share one circular footprint deliberately. A
   product's colours are usually a mix — some with their own photo, most
   with only a hex — and giving the two different shapes/sizes made the
   options wrap at ragged row heights (seen live: a 3.75rem photo tile
   beside 1.5rem dots produced 3-then-4-then-1 rows). One shape, one size,
   so any mix still reads as a single tidy row. Circles also match the
   filter bar's existing swatch dots rather than introducing a second
   swatch language. */
.variant-picker__photo,
.variant-picker__dot {
  display: block;
  width: 1.15rem;
  height: 1.15rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
  overflow: hidden;
  flex-shrink: 0;
}

.variant-picker__photo {
  background: var(--sand);
}

.variant-picker__photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

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

.buy-box {
  display: grid;
  gap: var(--space-2);
  padding: var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
}

.buy-box__price {
  font-size: var(--text-xl);
  font-family: var(--font-display);
  /* Regular, matching .pdp__title — see the note there for why this stops
     at 400 rather than going to a heavier weight. */
  font-weight: var(--font-weight-regular);
}

.buy-box__price-note,

.buy-box__in-stock {
  display: inline-flex;
  align-items: center;
  font-size: var(--text-sm);
}

.buy-box__delivery {
  display: flex;
  align-items: center;
  margin: 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.buy-box__form {
  display: grid;
  gap: var(--space-2);
  /* Anchor for the loading bar below. */
  position: relative;
}

/* The htmx indicator is invisible at rest but was still a grid child, so it
   consumed a full row plus the form's own gap — measured as a 42px void
   between Add to cart and Order via WhatsApp, most of it empty. Taking it
   out of flow leaves the two buttons a normal --space-2 apart. */

.buy-box__add,
.buy-box__whatsapp {
  text-align: center;
}

/* Confirmation, directly under the button that caused it. Empty until the
   response lands, so it reserves no space and shifts nothing until it has
   something to say. */
.buy-box__status:empty {
  display: none;
}

.buy-box__status {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  margin: 0;
  font-size: var(--text-sm);
  color: var(--color-accent);
}

/* No error-colour override here on purpose. .form-error already sets its
   own colour, deliberately not a red — DESIGN.md section 2 keeps colour
   from carrying meaning on its own. The success case is distinguished by
   its tick, which is a shape cue and survives greyscale. */

/* Loading feedback for add-to-cart lives on the button, not beside it.
   A 2px rule between Add to cart and Order via WhatsApp read as a hard
   divider drawn between the two buttons rather than as progress, so the
   control that was pressed now shows its own state instead.

   Same timing rule as .htmx-indicator above and for the same reason: the
   delay is on appearing only, so a request that finishes inside 400ms —
   which locally is all of them — never dims the button at all, while a
   slow one shows it and clears the instant the response lands.

   htmx puts .htmx-request on the element that made the request, which is
   the form itself. */
.buy-box__add {
  transition: background-color var(--duration-fast) ease-out, opacity 120ms ease-out;
}

.buy-box__form.htmx-request .buy-box__add {
  opacity: 0.6;
  cursor: progress;
  transition: opacity 160ms ease-out;
  transition-delay: 400ms;
}

/* ---- Quantity stepper ------------------------------------------------ */

/* Label hard left, control hard right, across the full width of the buy
   box — not inline-flex, which left the two hugging each other in the
   middle and made a 40px-tall control look oversized next to a 14px
   label. Separated, they read as a labelled row like the SKU and stock
   lines above, and the size difference stops being a comparison. */
.quantity {
  display: flex;
  width: 100%;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
}

/* Visible, not visually-hidden — a bare number between two buttons is
   guessable but not obvious. Matches the variant pickers' legend
   treatment (uppercase, tracked, muted) so the buy box and the info
   column read as one system rather than two. */
.quantity__label {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

/* One capsule around all three parts. A stepper is a single control, and
   drawing one border here — rather than one per button and one on the
   field — is what guarantees they share a height, instead of depending on
   three separate rules happening to agree. They previously didn't: the
   buttons rendered 36px tall against a 41px field. */
.quantity__control {
  display: inline-flex;
  align-items: stretch;
  height: 2.5rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
  overflow: hidden;
  transition: border-color var(--duration-fast) ease-out;
}

.quantity__control:focus-within {
  border-color: var(--cocoa);
}

.quantity__step {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.5rem;
  /* flex-shrink: 0 matters — without it the field's flex-basis squeezed
     the buttons to 27px wide, which is how the mismatch was spotted. */
  flex-shrink: 0;
  border: none;
  border-radius: 0;
  background: transparent;
  color: var(--color-text-primary);
  cursor: pointer;
  transition: background-color var(--duration-fast) ease-out;
}

.quantity__step .icon {
  margin-inline-end: 0;
}

.quantity__step:hover:not(:disabled) {
  background: var(--color-bg-secondary);
}

.quantity__step:disabled {
  opacity: 0.35;
  cursor: default;
}

/* Selector deliberately includes the element+attribute, not just the
   class. The shared `input[type="text"], …` rule has specificity 0-0-1-1,
   which beats a bare `.quantity__input` (0-0-1-0) — so its `width: 100%`
   was winning and stretching this field to 118px despite a 3.5rem
   max-width being set. Matching that specificity is the fix; overriding
   with !important would work too but hides the real reason. */
input[type="text"].quantity__input {
  width: 2.75rem;
  min-width: 0;
  max-width: none;
  height: 100%;
  padding: 0;
  border: none;
  border-radius: 0;
  background: transparent;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* The capsule already shows focus via :focus-within, so a second ring
   inside it would just be noise. */
input[type="text"].quantity__input:focus-visible {
  outline: none;
}

/* ---- Sticky mobile buy bar ------------------------------------------- */

/* Mobile only: above 48rem the buy box is already beside the content (and
   sticky in its own column at 85rem+), so a fixed bar would be redundant
   clutter. aria-hidden on the element itself — it duplicates controls that
   already exist above, so announcing it twice would just be noise. */
.pdp__sticky-bar {
  display: none;
}

@media (max-width: 47.99rem) {
  .pdp__sticky-bar {
    position: fixed;
    inset-inline: 0;
    inset-block-end: 0;
    z-index: 50;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-4);
    background: var(--color-bg-primary);
    border-block-start: 1px solid var(--color-border);
    /* Clears the iOS home-bar area so the button isn't half-covered. */
    padding-block-end: max(var(--space-2), env(safe-area-inset-bottom));
  }

  .pdp__sticky-price {
    font-size: var(--text-lg);
  }

  /* Reserve room so the bar never covers the footer's last line. */
  .pdp {
    padding-block-end: 4.5rem;
  }
}

/* ---- Highlights ------------------------------------------------------ */

.pdp__highlights {
  list-style: none;
  margin: 0 0 var(--space-3);
  padding: 0;
  display: grid;
  gap: var(--space-1);
  font-size: var(--text-sm);
}

.pdp__highlights li {
  display: flex;
  align-items: flex-start;
}

/* The tick sits on the first line of a wrapping bullet, not centred
   against the whole block. */
.pdp__highlights .icon {
  margin-block-start: 0.25em;
}

/* ---- Sale pricing ---------------------------------------------------- */

/* Three signals, never colour alone (DESIGN.md section 2): the strike is a
   shape cue, "Save PKR X" is text, and the rust accent is additive. Reads
   correctly in greyscale and to a colourblind shopper. */
/* Price and struck original share one baseline-aligned row — the pairing
   reads as "this, down from that", where stacking them read as two
   unrelated prices. */
.buy-box__price-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-1);
}

.buy-box__sale {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin: 0;
}

/* Smaller and muted: it's context for the real price, not a price the
   shopper is being asked to consider. */
.buy-box__was {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

/* The saving is the number a shopper actually reacts to, so it gets a
   soft rust wash rather than being plain text — but deliberately NOT a
   second solid chip, or it would compete with the percentage badge
   instead of pairing with it. */
.buy-box__saving {
  color: var(--color-attention);
  background: var(--color-attention-soft);
  border-radius: var(--radius-pill);
  padding-block: 0.15rem;
  padding-inline: var(--space-2);
}

/* The one filled use of the accent. Everywhere else it's text or a border,
   so a single solid rust chip stays the loudest thing on the page and
   doesn't have to compete with anything. */
.badge--attention {
  background: var(--color-attention);
  border-color: var(--color-attention);
  color: var(--sand);
}

/* ---- Tabs ------------------------------------------------------------ */

/* Underline-indicator style — DESIGN.md section 3's Tabs spec, written
   when the design system was drafted and only now actually built. */
.tabs {
  margin-block-start: var(--space-5);
}

.tabs__list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  border-block-end: 1px solid var(--color-border);
}

.tabs__tab {
  background: none;
  border: none;
  /* Square, not --radius-pill: these are underline indicators, not
     actionable pills, and rounding them would imply the wrong affordance. */
  border-radius: 0;
  border-block-end: 2px solid transparent;
  padding: 0 0 var(--space-2);
  margin-block-end: -1px;
  font-family: var(--font-body);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  cursor: pointer;
  transition: color var(--duration-fast) ease-out, border-color var(--duration-fast) ease-out;
}

.tabs__tab:hover {
  color: var(--color-text-primary);
}

/* Weight is deliberately unchanged — only two weights exist and 300 is
   reserved for display type, so the underline plus full-strength colour
   carry the active state (DESIGN.md section 3). */
.tabs__tab.is-active {
  color: var(--color-text-primary);
  border-block-end-color: var(--cocoa);
}

/* Hidden by default, shown by .is-active — not by x-show plus x-cloak.
   The panels are a class toggle now, the same mechanism .dropdown-panel
   already uses.

   This is a robustness change, not a style one. Under x-show, a panel was
   visible unless Alpine said otherwise, and x-cloak covered the gap before
   Alpine started. That leaves one failure mode: if the cloak is ever
   removed while the show is not applied, *every* panel renders at once and
   the whole tab strip reads as one long stacked page — reported from the
   live site, and not reproducible here across five navigation paths, a 6x
   CPU throttle and a deliberately delayed Alpine.

   Inverting the default removes the failure rather than chasing it. The
   server renders `is-active` on the description panel, so the correct
   single panel shows before any JavaScript runs and keeps showing even if
   Alpine never initialises at all. Alpine only ever moves the class.

   Toggling a class also avoids x-show's inline `display`, which is what
   made the old mechanism unfixable from CSS: an inline style outranks any
   rule a stylesheet could use to defend against this. */
.tabs__panel {
  display: none;
  padding-block-start: var(--space-3);
  /* Reading measure — correct for the prose panels, where a long line of
     text is genuinely harder to read. */
  max-width: var(--measure);
}

.tabs__panel.is-active {
  display: block;
}

/* …but not for reviews, which are a grid of bars, chips and rows rather
   than a paragraph. The measure cap left the content ending 131px short
   of the tab bar above it, which read as a misalignment rather than as
   comfortable line length. */
#panel-reviews {
  max-width: none;
}

.tabs__panel:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

.tabs__empty {
  color: var(--color-text-muted);
}

.tabs__facts {
  list-style: none;
  margin: 0 0 var(--space-2);
  padding: 0;
  display: grid;
  gap: var(--space-1);
}

.tabs__facts li {
  display: flex;
  align-items: center;
}

/* ---- Trust row ------------------------------------------------------- */

/* Honest content only: Cash on Delivery is the real (and only) payment
   method until Phase 3's gateway lands, so no card logos appear here yet. */
.trust-row li {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  line-height: 1.25;
}

.trust-row .icon {
  /* The shared .icon rule adds trailing space for inline use; here the
     icon sits above its label, so that margin would push it off-centre. */
  margin-inline-end: 0;
  width: 1.15rem;
  height: 1.15rem;
}

/* Grouping rules — price, then availability, then the actions. Without
   them the box read as one undifferentiated list of nine lines.

   margin-inline: 0 is load-bearing, not tidying. The UA stylesheet gives
   <hr> auto inline margins, and an auto margin cancels a grid item's
   stretch — the rule shrank to fit-content, which for an empty element is
   0px wide, so it rendered as nothing at all. Block margin is 0 because
   the buy box is a grid with its own 16px gap; the rule's own margins
   stacked on top of it and opened 32px trenches around each divider. */
.buy-box__rule {
  border: none;
  border-block-start: 1px solid var(--color-border-soft);
  margin: 0;
  inline-size: 100%;
}

/* Product metadata under the title: SKU, and fabric when there's only
   one. Moved out of the buy box, where every line should be something a
   shopper weighs before pressing the button. */
.pdp__meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-block-start: 0;
  margin-block-end: var(--space-3);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

/* Three columns, icon above label. Stacked, these three short facts used
   three full lines at the bottom of an already tall box; side by side
   they read as one reassurance block at a third of the height. */
.trust-row {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--space-1);
  text-align: center;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

/* Buttons — DESIGN.md section 3 Components > Buttons. Three variants, one
   size; compact reuses Ghost with tighter padding, not a fourth variant. */
.btn {
  display: inline-block;
  /* Pill, matching the filter bar's search input/dropdown toggles/chips
     (tokens.css) — every actionable control (a button you press, a chip
     you select) now shares one shape language site-wide. Form inputs
     stay --radius-sm, deliberately: a pill reads as "action/tag," a
     rectangle reads as "data entry" — the two are different affordances,
     not an inconsistency to fix. */
  border-radius: var(--radius-pill);
  padding-block: var(--space-2);
  padding-inline: var(--space-4);
  font-size: var(--text-sm);
  font-weight: var(--font-weight-regular);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  text-decoration: none;
  cursor: pointer;
  transition: background-color var(--duration-fast) ease-out;
}

.btn:disabled {
  opacity: 0.45;
  pointer-events: none;
}

.btn--primary {
  background: var(--color-accent);
  color: var(--sand);
  border: none;
}

.btn--primary:hover {
  background: color-mix(in srgb, var(--cocoa) 85%, black);
}

.btn--secondary {
  background: transparent;
  border: 1px solid var(--color-border);
  color: var(--color-text-primary);
}

.btn--secondary:hover {
  background: var(--color-bg-secondary);
}

.btn--ghost {
  background: transparent;
  border: none;
  color: var(--color-text-primary);
  padding-inline: 0;
  text-decoration: none;
}

.btn--ghost:hover,
.btn--ghost:focus-visible {
  text-decoration: underline;
}

/* Compact tightens BOTH axes. It previously reduced padding-inline only,
   which left every "compact" button at the full-size 55px height — far
   too heavy inside a 320px dropdown, where it read as a page-level CTA
   rather than a secondary action. Vertical padding drops to --space-1,
   giving roughly 39px: still a comfortable target, clearly subordinate
   to the ~48px primary actions on the page itself. */
.btn--compact {
  padding-block: var(--space-1);
  padding-inline: var(--space-2);
}

/* Form inputs — DESIGN.md section 3 Components > Form inputs */
input[type="number"],
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="search"],
textarea {
  /* Safari/iOS gives type="search" its own rounded native chrome by
     default, overriding border-radius otherwise — reset to the plain
     textfield appearance so it actually takes this system's tokens. */
  -webkit-appearance: none;
  appearance: none;
  border: 1px solid var(--color-border);
  background: var(--color-bg-primary);
  border-radius: var(--radius-sm);
  /* Vertical padding is the compact tier (--space-1), not --space-2 — a
     16px-each-side input read as oversized/"fatty" next to everything
     else on the page (57px tall vs. a 48px button). Font-size stays at
     --text-base regardless: dropping below 16px makes iOS Safari
     auto-zoom the page on focus, a real mobile regression on a
     mobile-first site, so the height comes down through padding only. */
  padding-block: var(--space-1);
  padding-inline: var(--space-2);
  font-family: var(--font-body);
  font-size: var(--text-base);
  color: var(--color-text-primary);
  width: 100%;
  max-width: 28rem;
}

input::placeholder,
textarea::placeholder {
  color: var(--color-text-muted);
}

/* Error state — border weight + color shift, paired with a text message.
   Never color alone (DESIGN.md section 2). Applied via .has-error, either
   directly on a field or on a wrapping .form-field (Django's default form
   rendering makes styling the wrapper easier than the widget itself). */
.has-error,
.form-field.has-error input,
.form-field.has-error textarea {
  border-width: 2px;
  border-color: var(--cocoa);
}

.form-field {
  /* --space-2, not --space-3 — a 24px gap between fields read as too
     loose once the fields themselves became compact (~38px). */
  margin-block-end: var(--space-2);
}

.form-error {
  color: var(--color-text-primary);
  font-size: var(--text-sm);
  margin-block-start: var(--space-1);
}

/* The default assumes a stacked form field — label on its own line above
   its input, which is how checkout and cart render. */
label {
  display: block;
  font-size: var(--text-sm);
  font-weight: var(--font-weight-regular);
  margin-block-end: var(--space-1);
}

/* …but three components lay a <label> out *inline* rather than stacked,
   where that bottom margin is wrong. It was doing real damage silently:
     - the quantity label sat 4px above centre, because flexbox centres an
       item including its margins (21px label + 8px margin in a 40px row
       lands at 5.5px from the top, not 9.5px);
     - every variant and filter chip carried 8px underneath, so chip grids
       were spaced 16px between rows against 8px between columns, when the
       container asked for 8px both ways.
   Reset here rather than scoping the rule above, because the stacked
   default is the right one for forms and these are the deliberate
   exceptions to it. */
.quantity__label,
.variant-picker__option,
.product-filters__chip {
  margin-block-end: 0;
}

/* ---- Cart, checkout and confirmation ---------------------------------
   The purchase flow. All three pages share .order-summary, so a line item
   cannot look different depending on which one you are standing on. */

/* Two columns from 64rem: the items, and a panel that stays in view while
   a long cart is scrolled. Below that, one column with the panel after the
   items — on a phone you scroll through what you are buying and arrive at
   the total, which is the order the decision happens in. */
.cart-layout {
  display: grid;
  gap: var(--space-4);
}

@media (min-width: 64rem) {
  .cart-layout {
    grid-template-columns: minmax(0, 1fr) 22rem;
    gap: var(--space-5);
    align-items: start;
  }

  .cart-layout__aside {
    position: sticky;
    inset-block-start: var(--space-4);
  }
}

/* --- The shared summary ------------------------------------------------ */

.order-summary__lines {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.order-summary__line {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  gap: var(--space-3);
  align-items: start;
  padding-block: var(--space-3);
}

.order-summary__line + .order-summary__line {
  border-block-start: 1px solid var(--color-border-soft);
}

/* 4.5rem, and square. The photo is here to be recognised, not studied —
   large enough to tell two hijabs apart at a glance, small enough that a
   cart of six still fits on one screen. */
.order-summary__thumb {
  position: relative;
  inline-size: 4.5rem;
  aspect-ratio: 1;
  border-radius: var(--radius-sm);
  overflow: hidden;
  background-color: var(--color-bg-secondary);
  background-size: cover;
  background-position: center;
  flex-shrink: 0;
}

/* <picture> has no intrinsic size and collapses to its image's ratio, so
   the height below would resolve against nothing. Same rule as the product
   photos — DESIGN.md section 8b. */
.order-summary__thumb picture {
  display: block;
  inline-size: 100%;
  block-size: 100%;
}

.order-summary__thumb img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
}

/* The quantity badge, for the read-only summaries where there is no
   stepper to carry the number. */
.order-summary__count {
  position: absolute;
  inset-block-start: 0.15rem;
  inset-inline-end: 0.15rem;
  min-inline-size: 1.15rem;
  padding-inline: 0.25rem;
  border-radius: var(--radius-pill);
  background: var(--color-accent);
  color: var(--sand);
  font-size: 0.7rem;
  line-height: 1.15rem;
  text-align: center;
}

.order-summary__detail {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  min-inline-size: 0;
}

.order-summary__name {
  margin: 0;
  font-size: var(--text-sm);
  line-height: 1.35;
}

.order-summary__variant {
  margin: 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.order-summary__line-end {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--space-1);
}

/* The unit price, under the line total. Nowrap, because it is one figure
   and a wrapped "7 × PKR" reads as two. */
.order-summary__unit {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.order-summary__line-total {
  font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Icon-only, muted, 2.75rem of target on a 1rem glyph. Removing something
   is destructive and easy to hit by accident, so it stays quiet — but the
   target does not shrink with the icon (DESIGN.md section 7). */
.order-summary__remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  inline-size: 2.75rem;
  block-size: 2.75rem;
  margin-inline-end: -0.75rem;
  padding: 0;
  border: none;
  border-radius: var(--radius-pill);
  background: none;
  color: var(--color-text-muted);
  cursor: pointer;
  transition: color var(--duration-fast) ease-out,
    background-color var(--duration-fast) ease-out;
}

.order-summary__remove:hover,
.order-summary__remove:focus-visible {
  color: var(--color-text-primary);
  background: var(--color-surface-soft);
}

.order-summary__totals {
  margin: 0;
  padding-block-start: var(--space-3);
  border-block-start: 1px solid var(--color-border-soft);
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.order-summary__row {
  display: flex;
  justify-content: space-between;
  gap: var(--space-2);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.order-summary__row dt,
.order-summary__row dd {
  margin: 0;
}

.order-summary__row dd {
  font-variant-numeric: tabular-nums;
}

/* The total is the number being decided on, so it steps up in size and
   colour while everything above it stays muted. */
.order-summary__row--total {
  margin-block-start: var(--space-1);
  padding-block-start: var(--space-2);
  border-block-start: 1px solid var(--color-border-soft);
  font-size: var(--text-base);
  color: var(--color-text-primary);
}

/* The compact summary — the checkout's narrow sticky sidebar, where the
   same line has roughly a third of the width it gets on the cart page.

   Measured at 1440px before touching anything: the panel gave each line
   288px, of which 72px went to the photo, 48px to two gutters and 85px to
   the price, leaving 83px for the product name. At 14px that is about nine
   characters, so "Georgette Hijab Soft for Women Fashion" wrapped onto four
   lines and one item stood 198px tall — nearly three times its own photo.
   Three items filled 694px of a 900px screen.

   Two things buy the width back: smaller gutters (24px between columns of
   an 83px column is not a proportion, it is an accident of reusing the
   page-level spacing token) and a smaller photo. The photo is here to be
   recognised, not studied, and 3.5rem still does that. */
.order-summary--compact .order-summary__line {
  gap: var(--space-2);
  padding-block: var(--space-3);
}

.order-summary--compact .order-summary__thumb {
  inline-size: 3.5rem;
}

/* On a phone the three columns stop fitting, and the price column is the
   one that refuses to give: it is unbreakable text, so it takes its
   125px and the name column absorbs the whole shortfall. Measured at
   320px it was reduced to nothing at all — 0px, the name rendered one
   character per line.

   So below 30rem the price drops to its own row under the name, which
   then has the full width beside the photo. Nothing is hidden to achieve
   it: a shopper about to pay should not have to take the total on trust
   because their screen is small. */
@media (max-width: 30rem) {
  .order-summary--compact .order-summary__line {
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas:
      "thumb detail"
      "thumb price";
    row-gap: var(--space-1);
  }

  .order-summary--compact .order-summary__thumb {
    grid-area: thumb;
  }

  .order-summary--compact .order-summary__detail {
    grid-area: detail;
  }

  /* row-reverse, so the line total keeps the trailing edge it has at every
     other width and the unit price sits inboard of it. */
  .order-summary--compact .order-summary__line-end {
    grid-area: price;
    flex-direction: row-reverse;
    align-items: baseline;
    justify-content: space-between;
    inline-size: 100%;
  }
}

/* Past four or so items the panel outgrows the screen, and it fails
   differently at each width. On desktop it is sticky, so everything below
   the fold stays below it for the whole form and the total — the number
   being agreed to — is never in view while anyone types. Below 64rem it is
   not sticky but sits *above* the form, so a twelve-line order put 2,107px
   of photographs between the page title and the first field.

   Both are the same fix: the list scrolls inside itself and the totals stay
   put. min() so the cap is whichever is smaller — 24rem, or a little over
   half the screen on a short one. It only engages when there is genuinely
   something to scroll, so a two-item order is untouched.

   Deliberately no `overscroll-behavior: contain`: chaining is wanted here.
   A finger or wheel that reaches the end of the list should carry on down
   the page rather than stopping dead on it. */
.order-summary--compact .order-summary__lines {
  max-block-size: min(24rem, 55vh);
  overflow-y: auto;
  padding-inline-end: var(--space-2);
  /* A visible track, not the overlay scrollbar that fades out. A cut-off
     list with no scrollbar is not a shortened list, it is a wrong one: the
     twelfth item ends level with a divider and the order simply looks like
     three items. `thin` reserves the gutter only when it actually
     overflows, so a short list is not indented by it. */
  scrollbar-width: thin;
  scrollbar-color: var(--color-border) transparent;
}

.order-summary__note {
  margin-block-start: var(--space-2);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  display: flex;
  align-items: center;
}

/* --- Cart checkout panel ---------------------------------------------- */

.cart-checkout {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-4);
  border-radius: var(--radius-lg);
  background: var(--color-surface-soft);
}

.cart-checkout__total {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-2);
  margin: 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.cart-checkout__total strong {
  font-size: var(--text-lg);
  font-weight: var(--font-weight-regular);
  color: var(--color-text-primary);
  font-variant-numeric: tabular-nums;
}

.cart-checkout__button {
  justify-content: center;
  text-align: center;
}

.cart-checkout__continue {
  align-self: center;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.cart-checkout__delivery {
  display: flex;
  align-items: center;
  margin: 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.cart-checkout .trust-row {
  margin-block-start: var(--space-2);
  padding-block-start: var(--space-3);
  border-block-start: 1px solid var(--color-border-soft);
}

/* --- Checkout ---------------------------------------------------------- */

.checkout {
  display: grid;
  gap: var(--space-4);
}

.checkout__aside {
  padding: var(--space-4);
  border-radius: var(--radius-lg);
  background: var(--color-surface-soft);
}

@media (min-width: 64rem) {
  /* 26rem, not the 22rem this started at. The form beside it is a single
     column of fields that had width to spare, and the summary did not: at
     22rem the product name was down to 83px. */
  .checkout {
    grid-template-columns: minmax(0, 1fr) 26rem;
    gap: var(--space-5);
    align-items: start;
  }

  .checkout__aside {
    position: sticky;
    inset-block-start: var(--space-4);
  }
}

/* Below 64rem the summary comes first, so the total is visible before
   anyone starts typing — nobody should fill in nine fields to discover
   what they are about to agree to. */
@media (max-width: 63.99rem) {
  .checkout__aside {
    order: -1;
  }
}

.checkout__aside-title,
.order-done__heading {
  margin: 0 0 var(--space-2);
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  font-size: var(--text-lg);
}

/* Baseline-aligned with the heading rather than beside it as an equal —
   it is a fact about the order, not a second title. */
.checkout__aside-count {
  margin-inline-start: var(--space-2);
  font-family: var(--font-body);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.checkout__edit {
  display: inline-block;
  margin-block-start: var(--space-3);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.checkout__error {
  margin-block-end: var(--space-3);
}

.checkout__section {
  margin: 0 0 var(--space-4);
  padding: 0;
  border: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.checkout__section legend {
  padding: 0;
  margin-block-end: var(--space-2);
  font-size: var(--text-sm);
  text-transform: uppercase;
  letter-spacing: var(--track-ui);
  color: var(--color-text-muted);
}

/* City / Province / Postal code share a row from 48rem. Three full-width
   rows for three short values made the form look twice as long as it is. */
.checkout__row {
  display: grid;
  gap: var(--space-2);
}

@media (min-width: 48rem) {
  .checkout__row {
    grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 0.8fr);
    gap: var(--space-3);
    align-items: start;
  }
}

/* The shared input rule caps every field at 28rem, which is right in a
   narrow column but leaves the address field short of the form width.
   input[type=...] is (0-0-1-1) and would tie with a bare class, so the
   selector is deliberately more specific — DESIGN.md section 8b rule 2,
   rather than !important. */
.checkout__form input[type="text"],
.checkout__form input[type="email"],
.checkout__form input[type="tel"],
.checkout__form textarea {
  max-width: none;
}

.payment-method {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  padding: var(--space-3);
  margin-block-end: var(--space-4);
  border: 1px solid var(--color-accent);
  border-radius: var(--radius-lg);
  background: var(--color-surface-soft);
}

.payment-method__mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  inline-size: 1.5rem;
  block-size: 1.5rem;
  flex-shrink: 0;
  border-radius: var(--radius-pill);
  background: var(--color-accent);
  color: var(--sand);
}

.payment-method__mark .icon {
  margin-inline-end: 0;
  inline-size: 0.9rem;
  block-size: 0.9rem;
}

.payment-method__name {
  margin: 0;
  font-size: var(--text-sm);
}

.payment-method__note {
  margin: 0.2rem 0 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.checkout__submit {
  inline-size: 100%;
  justify-content: center;
  text-align: center;
}

.checkout__reassurance {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-block-start: var(--space-2);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

/* --- Steps ------------------------------------------------------------- */

.checkout-steps {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin: 0 0 var(--space-3);
  padding: 0;
  font-size: var(--text-sm);
  text-transform: uppercase;
  letter-spacing: var(--track-ui);
  color: var(--color-text-muted);
}

.checkout-steps li + li::before {
  content: "";
  display: inline-block;
  inline-size: 1.5rem;
  block-size: 1px;
  margin-inline-end: var(--space-2);
  vertical-align: middle;
  background: var(--color-border-soft);
}

.checkout-steps .is-current {
  color: var(--color-text-primary);
}

.checkout-steps a {
  color: inherit;
}

/* --- Confirmation ------------------------------------------------------ */

.order-done {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-4);
  max-width: 48rem;
}

.order-done__header {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.order-done__mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  inline-size: 2.75rem;
  block-size: 2.75rem;
  margin-block-end: var(--space-1);
  border-radius: var(--radius-pill);
  background: var(--color-accent);
  color: var(--sand);
}

.order-done__mark .icon {
  margin-inline-end: 0;
  inline-size: 1.4rem;
  block-size: 1.4rem;
}

.order-done__number {
  margin: 0;
  font-size: var(--text-sm);
  text-transform: uppercase;
  letter-spacing: var(--track-ui);
  color: var(--color-text-muted);
}

.order-done__next {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  inline-size: 100%;
  padding: var(--space-3);
  border-radius: var(--radius-lg);
  background: var(--color-surface-soft);
}

.order-done__next p {
  margin: 0;
  font-size: var(--text-sm);
}

.order-done__panels {
  display: grid;
  gap: var(--space-4);
  inline-size: 100%;
}

@media (min-width: 48rem) {
  .order-done__panels {
    grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
    gap: var(--space-5);
    align-items: start;
  }
}

.order-done__address {
  margin: 0;
  font-style: normal;
  font-size: var(--text-sm);
  line-height: 1.7;
  color: var(--color-text-muted);
}

.page-title {
  margin: 0 0 var(--space-3);
}

/* Loading feedback — HTMX hx-indicator. A subtle bar, never a spinner
   overlay, per PLAN.md's perceived-quality-polish table. Always present
   in the DOM (a fixed 2px reserved, never a layout shift) — htmx's own
   injected stylesheet handles the opacity fade between "idle" and
   ".htmx-request", this only styles the bar's shape and color. */
.loading-bar {
  height: 2px;
  margin-block-start: var(--space-1);
  background: var(--color-accent);
}

/* An indicator only appears if the request is actually slow enough to
   need one.

   htmx's own rule fades every indicator in the moment a request starts.
   Add to cart answers in about 140ms locally, so the bar under the button
   was visible for ~83ms and never got past 0.41 opacity before being told
   to fade back out — measured, not estimated. A 2px line that flickers to
   40% and vanishes tells a shopper nothing; it just reads as the page
   glitching between the two buttons.

   The delay is on appearing only, and deliberately not on disappearing:
   a request that finishes inside 400ms never shows the bar at all, while
   a slow one still gets its feedback and then clears it the instant the
   response lands. Putting the same delay on the way out would leave the
   bar lingering after the work was done, which is the opposite problem.

   400ms because below roughly that a click still feels instant, so an
   indicator is answering a question nobody had time to ask. The real
   confirmation is the status line and the cart badge, both of which
   update regardless.

   The opacity states are declared here rather than left to htmx: base.html
   sets includeIndicatorStyles to false, because htmx injects its stylesheet
   after this file loads and would otherwise win the cascade at equal
   specificity. So this rule is the whole behaviour, not an override.

   Both selectors are htmx's own: the indicator can be a descendant of the
   requesting element or be that element itself. */
.htmx-indicator {
  opacity: 0;
  transition: opacity 120ms ease-out;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  opacity: 1;
  transition: opacity 160ms ease-out;
  transition-delay: 400ms;
}

/* --- Wishlist (Stage C) ------------------------------------------------
   Browser-local only — see static/js/wishlist.js for why there is no
   server side to this. */

/* Wraps the wishlist and cart links. This, not the cart itself, is now
   the third grid child of the header — the cart's own grid-column was
   removed when it stopped being a direct child. */
.site-header__actions {
  grid-column: 3;
  justify-self: end;
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

/* The heart is a circular tap target sitting over the card corner. It is
   NOT inside the card's <a> — a button inside a link is invalid HTML and
   would navigate instead of saving. */
.wishlist-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.5rem;
  height: 2.5rem;
  flex-shrink: 0;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
  background: var(--color-bg-primary);
  color: var(--color-text-primary);
  cursor: pointer;
  transition: border-color var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
}

.wishlist-btn .icon {
  margin-inline-end: 0;
}

.wishlist-btn:hover {
  border-color: var(--cocoa);
}

/* Saved state uses the attention accent — one of the meanings section 2
   allows it for. Never colour alone: the icon itself changes from an
   outline to a filled heart, and aria-pressed carries the state for
   anyone not seeing either. */
.wishlist-btn.is-saved {
  color: var(--color-attention);
  border-color: var(--color-attention);
}

.wishlist-btn--sm {
  position: absolute;
  inset-block-start: var(--space-2);
  inset-inline-end: var(--space-2);
  width: 2rem;
  height: 2rem;
  z-index: 1;
  font-size: var(--text-sm);
}

.pdp__title-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-2);
}

/* --- Social proof (Stage C) --------------------------------------------
   Both figures are real (services/social_proof.py) and both hide
   themselves below a threshold, so this block is often absent entirely. */

.social-proof {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  /* stretch, not flex-start — the live row below is a full-width panel
     rather than a chip that hugs its text. */
  align-items: stretch;
  gap: var(--space-1);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.social-proof li {
  display: flex;
  align-items: center;
}

/* The live count is the focal one — the only figure on the page that
   changes while someone is looking at it. A soft rust wash and full text
   colour lift it above the sold count, which stays plain so the two
   don't compete.

   A full-width panel with soft corners, NOT another pill. The buy box had
   accumulated pills at every level — the discount badge, the savings
   chip, the quantity capsule, both buttons — and one shape repeated that
   often stops distinguishing anything. --radius-lg is the surface tier
   (shared with the hero band and product images), which is what this
   is: a small band of information, not a chip that hugs its label. */
.social-proof__live {
  padding-block: var(--space-1);
  padding-inline: var(--space-2);
  border-radius: var(--radius-lg);
  background: var(--color-attention-soft);
  color: var(--color-attention);
}

/* A stone dropped in water: a solid centre with two rings expanding out
   of it and fading, the second offset by half the cycle so there is
   always one mid-flight. Rings are pseudo-elements on the dot rather than
   extra markup, and they scale rather than animating width/height — a
   transform is composited, so this costs no layout work per frame. */
.social-proof__pulse {
  position: relative;
  width: 0.5rem;
  height: 0.5rem;
  flex-shrink: 0;
  margin-inline-end: 0.6em;
  border-radius: var(--radius-pill);
  background: var(--color-attention);
}

.social-proof__pulse::before,
.social-proof__pulse::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--radius-pill);
  border: 1px solid var(--color-attention);
  animation: social-proof-ripple 2.6s cubic-bezier(0.2, 0.6, 0.3, 1) infinite;
}

.social-proof__pulse::after {
  animation-delay: 1.3s;
}

@keyframes social-proof-ripple {
  0% {
    transform: scale(1);
    opacity: 0.55;
  }
  100% {
    transform: scale(3.4);
    opacity: 0;
  }
}

/* DESIGN.md section 5: motion is decoration here, so it goes entirely
   rather than being slowed. The dot itself stays — the state it marks is
   still worth showing. */
@media (prefers-reduced-motion: reduce) {
  .social-proof__pulse::before,
  .social-proof__pulse::after {
    animation: none;
    opacity: 0;
  }
}

/* --- Chip (shared) -----------------------------------------------------
   The bordered pill toggle, used by the filter bar, the product page's
   colour/fabric picker and the review filters. DESIGN.md section 8b.

   These were three components declaring the same identity — pill radius,
   1px Taupe border, --text-sm Jost, Cocoa text, pointer cursor, a Cocoa
   border on hover, a var(--duration-fast) ease-out transition — in three places, which is
   how they drifted: three different transition property lists for what is
   visually one control.

   Padding, display and line-height stay with each component and are
   deliberately NOT unified. The filter chips, variant options and review
   filters were each sized on direct feedback ("make the filter buttons
   compact", "the review filters compact"), so they are three considered
   sizes of one component, not an inconsistency to flatten. This class owns
   what the chip *is*; each component owns how big its chips are. */
.chip {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
  background: transparent;
  font-family: var(--font-body);
  font-size: var(--text-sm);
  color: var(--color-text-primary);
  cursor: pointer;
  transition: background-color var(--duration-fast) ease-out,
    border-color var(--duration-fast) ease-out,
    color var(--duration-fast) ease-out;
}

.chip:hover {
  border-color: var(--cocoa);
}

/* --- Dropdown panel (shared) -------------------------------------------
   One floating panel component, used by the header's cart and wishlist
   previews and by the filter bar's colour/fabric menus. DESIGN.md section
   8b: a repeated block belongs in one place the moment a second thing
   needs it.

   These had drifted into two different components doing the same job:
   radius-lg against radius-sm, a shadow against none, z-index 30 against
   20, 24rem of height against 16rem, and — most visibly — two different
   animations, an Alpine opacity fade against a CSS fade-and-slide. Opening
   one then the other did not feel like the same website.

   Motion is the tokens above: --duration-panel, --ease-panel. The panel
   fades, rises a little and settles from a hair under full size, which
   reads as springy without overshooting.

   Hidden with visibility, not display, for two reasons: a transition
   cannot run on display, and visibility: hidden also takes the panel out
   of the accessibility tree and out of the tab order, so a closed panel is
   not something a screen reader or keyboard user can reach into. It also
   means no x-cloak is needed — the panel is hidden from first paint by CSS
   rather than by Alpine, so it cannot flash before Alpine boots. */
.dropdown-panel {
  position: absolute;
  inset-block-start: calc(100% + var(--space-1));
  z-index: 30;
  max-height: 24rem;
  overflow-y: auto;
  padding: var(--space-2);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  background: var(--color-bg-primary);
  font-size: var(--text-sm);
  /* The one shadow in the system. DESIGN.md keeps product cards flat
     because they sit *in* the page; this floats *above* it and has to read
     as detached from whatever it covers. */
  box-shadow: 0 8px 24px rgb(74 63 53 / 12%);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-0.5rem) scale(0.98);
  transition: opacity var(--duration-panel) var(--ease-panel),
    transform var(--duration-panel) var(--ease-panel),
    visibility var(--duration-panel);
}

.dropdown-panel.is-open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0) scale(1);
}

/* Section 7: motion is decoration here, never the thing that conveys the
   state. The panel still appears and disappears, it just stops moving. */
@media (prefers-reduced-motion: reduce) {
  .dropdown-panel {
    transition: opacity 1ms linear, visibility 1ms linear;
    transform: none;
  }

  .dropdown-panel.is-open {
    transform: none;
  }
}

/* --- Header actions: cart + wishlist ----------------------------------
   Both are the same component (.nav-action): an icon carrying a count
   badge, a label, and a panel that previews what's inside. One set of
   rules rather than one per feature — DESIGN.md section 8b. */

.nav-action {
  position: relative;
  display: inline-flex;
}

.nav-action__link {
  display: inline-flex;
  align-items: center;
  /* Wide enough to clear the badge, which overhangs the icon's right
     edge — measured overlapping the label by 6px at the previous 0.4em. */
  gap: var(--space-1);
  text-decoration: none;
  font-size: var(--text-sm);
}

.nav-action__link:hover,
.nav-action__link:focus-visible {
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* The badge is positioned against this, not the link, so it sits on the
   icon rather than floating past the label. */
.nav-action__icon-wrap {
  position: relative;
  display: inline-flex;
}

.nav-action__icon-wrap .icon {
  margin-inline-end: 0;
}

/* A count badge instead of "(2)" in the link text. Parenthesised digits
   read as a note; a filled counter reads as a quantity, which is what a
   shopper is scanning for. Cocoa fill and Sand text is the same pairing
   the primary button uses, so it needs no new colour. min-width plus
   equal padding keeps a single digit circular and lets a double digit
   grow into a capsule rather than clipping. */
.nav-badge {
  position: absolute;
  inset-block-start: -0.45rem;
  /* Anchored to the icon's trailing edge, not offset from its leading
     one: with inset-inline-start the overhang grew with the badge's own
     width, so a two-digit count pushed further into the label. Anchoring
     here keeps the overhang fixed at 0.35rem however wide it gets — and
     is the correct logical property for RTL. */
  inset-inline-end: -0.35rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.05rem;
  height: 1.05rem;
  padding-inline: 0.28rem;
  border-radius: var(--radius-pill);
  background: var(--color-accent);
  color: var(--sand);
  font-size: 0.66rem;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

@media (max-width: 47.99rem) {
  /* The words go on phones; icon and badge stay. Three header items
     competing for one row at that width, and the icons are unambiguous. */
  .nav-action__label {
    display: none;
  }
}

/* --- Hover panels ------------------------------------------------------
   Preview of what's in the cart / wishlist. Opens on hover with a short
   delay both ways (see hoverPanel in static/js/ui.js), and on click or
   focus for touch and keyboard, where hover doesn't exist. */

/* Only what makes this the header's panel; everything else is
   .dropdown-panel. Anchored to the inline-end because its triggers sit at
   the end of the header and a start-anchored panel would hang off-screen. */
.nav-panel {
  inset-inline-end: 0;
  width: min(20rem, calc(100vw - 2 * var(--space-4)));
}

/* Below 48rem the panel hangs off the header, not off its own action —
   the same correction the filter panels needed, for the same reason.

   The width is capped to the viewport, but it is right-anchored to an
   action sitting well inside the page, so on a 320px screen the wishlist
   panel ran from -11px to 255px: the cap kept it 266px wide and the anchor
   then pushed that off the *left* edge. Anchoring to the header instead
   puts its right edge at the page edge, where the cap actually means what
   it says.

   .nav-action only carries position for this panel; the count badge is
   anchored to .nav-action__icon-wrap, so releasing it here leaves the
   badge exactly where it was. */
@media (max-width: 47.99rem) {
  .nav-action {
    position: static;
  }
}

.nav-panel__list {
  list-style: none;
  margin: 0 0 var(--space-2);
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.nav-panel__item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.nav-panel__thumb {
  width: 3rem;
  height: 3.75rem;
  flex-shrink: 0;
  overflow: hidden;
  border-radius: var(--radius-sm);
  background: var(--sand);
}

.nav-panel__thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.nav-panel__detail {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  text-decoration: none;
  min-width: 0;
}

.nav-panel__name {
  /* Long product names must not push the price out of the panel. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.nav-panel__meta {
  color: var(--color-text-muted);
}

.nav-panel__total {
  display: flex;
  justify-content: space-between;
  padding-block-start: var(--space-2);
  border-block-start: 1px solid var(--color-border);
}

.nav-panel__actions {
  display: flex;
  gap: var(--space-1);
  margin-block-start: var(--space-2);
}

.nav-panel__actions .btn {
  flex: 1;
  text-align: center;
}

/* --- Empty state -------------------------------------------------------
   Shared by the cart panel, the wishlist panel and the wishlist page.
   Someone reaching an empty container was already interested enough to
   open it, so this offers the action that fills it rather than just
   reporting that it's empty. */

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--space-1);
  padding-block: var(--space-4);
  padding-inline: var(--space-2);
}

/* A ringed circle rather than a bare glyph — at this size a lone outline
   icon reads as an error mark. The Oat fill and Taupe stroke keep it
   quiet: it's scene-setting, not the thing to look at. The button is. */
.empty-state__icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 3.25rem;
  height: 3.25rem;
  margin-block-end: var(--space-1);
  border-radius: var(--radius-pill);
  background: var(--color-bg-secondary);
  color: var(--color-border);
}

.empty-state__icon .icon {
  width: 1.5rem;
  height: 1.5rem;
  margin-inline-end: 0;
}

.empty-state__title {
  font-size: var(--text-base);
  margin: 0;
}

.empty-state__message {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  margin: 0;
  max-width: 22ch;
}

.empty-state__cta {
  margin-block-start: var(--space-2);
}

/* --- Reviews (Stage D) -------------------------------------------------
   Star glyphs are text, not images or SVG: they scale with font size,
   inherit colour, and need no sprite entry. The filled/empty distinction
   is colour plus the glyph's own weight, and every rating also carries a
   real text label for anyone not seeing either (DESIGN.md section 2). */

.stars {
  display: inline-flex;
  align-items: center;
  gap: 0.1em;
  line-height: 1;
}

/* Sized in em so a star row scales with whatever text it sits beside —
   1.15em makes it read as solid rather than spindly, which the
   substituted OS glyph did at the same nominal size. */
.stars__star {
  width: 1.15em;
  height: 1.15em;
  flex-shrink: 0;
  color: var(--color-border);
}

.stars__star.is-filled {
  color: var(--color-attention);
}

/* One row: score card, distribution, write action. Stacking these cost
   roughly three times the height and pushed the reviews themselves out of
   view — which are what someone opened the tab to read. */
/* A soft panel, not a ruled-off row. This is the first thing a shopper
   looks at in the Reviews tab, so it should read as one considered block —
   but lightly: a wash and a radius, no border and no shadow. A full card
   here would compete with the product photography the page is built
   around.

   The hard bottom rule it used to carry is gone rather than kept: the box
   now does the separating, and a border under a filled panel is the same
   job stated twice. */
.rating-summary {
  display: grid;
  gap: var(--space-3);
  align-items: center;
  padding: var(--space-4);
  margin-block-end: var(--space-4);
  background: var(--color-surface-soft);
  border-radius: var(--radius-lg);
}

@media (min-width: 48rem) {
  .rating-summary {
    grid-template-columns: auto minmax(0, 1fr) auto;
    gap: var(--space-4);
  }
}

/* Boxed, so the headline number reads as a unit rather than floating
   above the bars. */
.rating-summary__score {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* space-evenly, not center: centring clustered the three lines into a
     tight block with all the slack pooled above and below, which made the
     box look padded rather than composed. Distributing them uses the
     square's height instead of leaving it empty. */
  justify-content: space-evenly;
  text-align: center;
  padding-block: var(--space-2);
  padding-inline: var(--space-3);
  /* Sand on the wash, with a hairline — a light card sitting on a tint.
     It used to be solid Oat behind a full-strength Taupe border, which
     made the score the darkest thing in the Reviews tab and left it
     shouting on a panel that had no box of its own. Now the panel groups
     and the score sits on it, lighter than its surroundings rather than
     heavier. */
  border: 1px solid var(--color-border-soft);
  border-radius: var(--radius-lg);
  background: var(--color-bg-primary);
  /* The three lines are one fact — the score — so they sit close
     together. At the inherited 1.5 they read as three separate items. */
  line-height: 1.25;
}

@media (min-width: 48rem) {
  /* Square only here, where the box sits in an auto-width grid column and
     its width comes from its widest line. Applied unconditionally it was
     a disaster on a phone: the box stretches to the full column there, so
     aspect-ratio matched the height to it and produced a 341x341 block —
     measured, not guessed. A near-square reads as a mistake; an exact one
     reads as deliberate, but only where the width is content-sized. */
  .rating-summary__score {
    aspect-ratio: 1;
    min-width: 6.5rem;
    padding-block: 0;
  }
}

/* --text-lg (20px), not --text-xl (up to 28px). At 28px against 14px
   metadata the number dominated the panel it was only meant to
   introduce. Stepping to the next real token keeps it clearly the
   headline without introducing an off-scale size. */
/* --font-weight-regular (400), not light (300): this is the number the
   whole panel exists to state, so it carries the heavier of the two
   weights the brand ships. Deliberately NOT a bold value — only 300 and
   400 are vendored, so anything heavier would be synthesised by the
   browser, and faux bold smears the letterforms. If a genuinely bolder
   score is ever wanted, a third Jost weight has to be vendored first. */
.rating-summary__average {
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  font-size: var(--text-lg);
  line-height: 1;
  margin: 0;
}

.rating-summary__count {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  /* Margins removed — space-evenly on the parent owns the distribution
     now, and a margin on top of it would bias one gap. */
  margin: 0;
  white-space: nowrap;
}

.rating-summary__breakdown {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  /* Tightening the line boxes to 1.2 (below) made 0.15rem too close —
     the rows needed the gap back once they stopped carrying their own
     slack. */
  gap: 0.45rem;
}

.rating-summary__row {
  display: grid;
  grid-template-columns: 2.2rem minmax(0, 1fr) 2.5rem;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--text-sm);
  /* Body line-height (1.5) made each row 21px tall for a 6px bar, which
     is what made the stack read as loose — the gap itself was already
     small. Tightening the line box does more than shrinking the gap. */
  line-height: 1.2;
  color: var(--color-text-muted);
}

.rating-summary__bar {
  height: 0.4rem;
  border-radius: var(--radius-pill);
  background: var(--color-bg-secondary);
  overflow: hidden;
}

.rating-summary__bar-fill {
  display: block;
  height: 100%;
  background: var(--color-attention);
  border-radius: var(--radius-pill);
}

.rating-summary__row-count {
  text-align: end;
  font-variant-numeric: tabular-nums;
}

/* --- Review filter chips --- */

.review-filters {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-block-end: var(--space-3);
}

/* Same pill language as the catalogue filter chips, so a shopper who has
   used one already knows this one. */
.review-filters__chip {
  /* Tighter than .btn--compact: these are filters sitting above the
     content they filter, not actions competing with the buy button. */
  padding-block: 0.3rem;
  padding-inline: 0.7rem;
}

.review-filters__chip.is-selected {
  background: var(--cocoa);
  border-color: var(--cocoa);
  color: var(--sand);
}

.reviews__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* A rule between reviews rather than a gap — it separates them in less
   space, and makes where one ends unambiguous.

   45% Taupe, matching .buy-box__rule. At full strength a divider repeated
   once per review became the most prominent thing in the list, ruling the
   page into boxes instead of quietly marking where one person's words
   end and the next begin. */
.reviews__list > li + li {
  margin-block-start: var(--space-3);
  padding-block-start: var(--space-3);
  border-block-start: 1px solid var(--color-border-soft);
}

.reviews__write-empty {
  text-align: center;
  margin-block-start: var(--space-2);
}

/* Stars, name, badge and date on one line, date pushed to the far edge. */
.review__header {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
  font-size: var(--text-sm);
}

.review__author {
  color: var(--color-text-primary);
}

.review__date {
  color: var(--color-text-muted);
}

@media (min-width: 48rem) {
  /* Right-aligned only where there's room for it. On a phone the auto
     margin pushed the date onto a line of its own, costing an extra line
     per review — it simply flows after the badge instead. */
  .review__date {
    margin-inline-start: auto;
  }
}

.review__title {
  font-size: var(--text-base);
  font-weight: var(--font-weight-regular);
  margin-block: var(--space-1) 0;
}

/* A bordered pill rather than loose coloured text — it reads as a badge
   awarded rather than an adjective, which is what it is. The one place
   the attention accent means "trustworthy" rather than "urgent", and
   still never colour alone: there is a tick and the words too. */
.review__verified {
  display: inline-flex;
  align-items: center;
  padding-block: 0.1rem;
  padding-inline: var(--space-1);
  border: 1px solid var(--color-attention);
  border-radius: var(--radius-pill);
  color: var(--color-attention);
  font-size: var(--text-sm);
  white-space: nowrap;
}

.review__photos {
  list-style: none;
  margin: var(--space-2) 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
}

.review__photos img {
  width: 5rem;
  height: 6.25rem;
  object-fit: cover;
  border-radius: var(--radius-sm);
  background-size: cover;
  background-position: center;
}

/* The same soft panel as .rating-summary, and deliberately the same rather
   than a variation: they are the two blocks that bracket the review list,
   so a shopper meets one at the top and the other at the bottom. Matching
   them makes the tab read as one designed surface.

   It matters more here than there. This form appears out of nowhere when
   "Write a review" is pressed — nine controls arriving mid-page with no
   container looked like the page had come apart. The panel gives it an
   edge, so it reads as a thing that opened rather than markup that
   escaped.

   Full width, matching the summary panel and the review list rather than
   the 32rem it was capped at. That cap was invisible while the form had
   no background; the moment it became a panel, a 512px box sitting under
   a 992px one read as misaligned.

   Widening the panel is not the same as widening the fields, though — a
   992px name input is a worse form, not a better one. So the panel spans
   the column and the fields sit in a two-column grid inside it, which
   uses the width instead of leaving 480px of empty wash to the right of
   every input. Name and phone pair up; anything longer than a short
   answer spans both. */
.review-form {
  margin-block-start: var(--space-3);
  padding: var(--space-4);
  background: var(--color-surface-soft);
  border-radius: var(--radius-lg);
}

@media (min-width: 48rem) {
  .review-form {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-2) var(--space-3);
    align-items: start;
  }

  /* The rating, the long fields and the submit row each take the full
     width. The honeypot and the CSRF input never become grid items —
     .visually-hidden is absolutely positioned and a hidden input is
     display:none — so neither leaves a gap in the layout. */
  .review-form__rating,
  .review-form .form-field--wide,
  .review-form__submit {
    grid-column: 1 / -1;
  }

  /* Their controls fill the row too. The shared input rule caps every
     field at 28rem, which is right for a checkout column but left these
     full-width rows with an input on the left and 480px of empty wash on
     the right — the row spanned, the control didn't, so it read as a
     layout that had half failed. The cap still applies to name and phone,
     which sit in single columns and want it. */
  .review-form .form-field--wide input,
  .review-form .form-field--wide textarea {
    max-width: none;
  }
}

.review-form__submit {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-1);
}

/* The file input was the last browser-default widget in the form: a grey
   system "Choose Files" button sitting inside a panel where everything
   else is a Jost pill. ::file-selector-button styles the real control
   rather than the usual trick of hiding the input behind a fake label —
   so keyboard focus, the file picker and the "N files selected" text all
   stay native, and there is no JavaScript to keep in sync.

   Styled to match .chip, since that is what a small bordered control
   looks like here. The text beside it is drawn by the browser and cannot
   be reworded, but it inherits the font and colour set on the input. */
input[type="file"] {
  font-family: var(--font-body);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  max-width: 100%;
}

input[type="file"]::file-selector-button {
  margin-inline-end: var(--space-2);
  padding-block: 0.3rem;
  padding-inline: 0.9rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
  background: transparent;
  font-family: var(--font-body);
  font-size: var(--text-sm);
  text-transform: uppercase;
  letter-spacing: var(--track-ui);
  color: var(--color-text-primary);
  cursor: pointer;
  transition: border-color var(--duration-fast) ease-out,
    background-color var(--duration-fast) ease-out;
}

input[type="file"]::file-selector-button:hover {
  border-color: var(--cocoa);
}

.review-form__rating {
  border: none;
  padding: 0;
  margin: 0 0 var(--space-3);
}

.review-form__rating legend {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 0;
  margin-block-end: var(--space-1);
}

/* row-reverse plus the template emitting 5→1 is what makes a pure-CSS
   hover preview possible: ~ only reaches later siblings, so in reverse
   order "the stars to the left of this one" become the ones CSS can
   select. Alpine handles the interactive fill; this is the no-JS
   fallback, and the reason the markup order looks backwards. */
.review-form__stars {
  display: flex;
  flex-direction: row-reverse;
  justify-content: flex-end;
  gap: 0.15em;
  font-size: var(--text-lg);
}

.review-form__star {
  display: inline-flex;
  cursor: pointer;
  color: var(--color-border);
  margin-block-end: 0;
  transition: color var(--duration-fast) ease-out;
}

/* Same SVG star the ratings use — the text glyph fell back to an OS font
   here too, so the control a customer taps looked different from the
   stars they had just been reading. Larger than a display star because
   it is a touch target, not a label. */
.review-form__star-icon {
  width: 1.6rem;
  height: 1.6rem;
}

.review-form__star.is-on,
.review-form__star:hover,
.review-form__star:hover ~ .review-form__star {
  color: var(--color-attention);
}

.review-form__star:has(input:focus-visible) {
  outline: var(--focus-ring);
  outline-offset: var(--focus-ring-offset);
}

.form-hint {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  margin-block-start: var(--space-1);
}

/* --- Flash messages ----------------------------------------------------
   Rendered site-wide from base.html. Never colour alone (DESIGN.md
   section 2): each variant is distinguished by its border weight and the
   message text itself, with the attention accent only reinforcing an
   error. */

.messages {
  list-style: none;
  margin: 0 0 var(--space-4);
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.messages__item {
  padding-block: var(--space-2);
  padding-inline: var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  background: var(--color-bg-secondary);
  font-size: var(--text-sm);
}

.messages__item--error {
  border-width: 2px;
  border-color: var(--color-attention);
  color: var(--color-attention);
  background: var(--color-attention-soft);
}
