/* ============================================================
   Common CSS base — loaded once via index.html.
   Design-token custom properties, resets, base typography and the
   cross-cutting utility classes (container, section header,
   breadcrumb, ripple). Component-specific styling lives in each
   component's own scoped *.razor.css file.
   Ported from src/styles/global.scss (+ _tokens.scss) of the React app.
   ============================================================ */

/* ============================================================
   DESIGN TOKEN LAYER — the single declaration of record.
   Every value below is carried across from the prototypes
   unchanged; the type, spacing, motion and breakpoint scales are
   *derived* from the values the component stylesheets already
   use, not invented. A step that looks awkward is a faithful
   step: snapping it to a tidier number would change rendering.
   Components reference these tokens and never repeat a literal.
   ============================================================ */
:root {
  /* ---- Identity colour (unchanged) ---- */
  --primary-black: #0a0a0a;
  --yellow-accent: #e8b800;
  --yellow-hover: #f5c518;
  --yellow-light: #fff8e1;
  --yellow-soft: #fff3cd;
  --white: #ffffff;
  --off-white: #f8f7f4;
  --glass-bg: rgba(255, 255, 255, 0.82);
  --glass-border: rgba(0, 0, 0, 0.08);
  --text-dark: #1a1a1a;
  --text-muted: #6b7280;
  --subtle-gray: #f0ede8;
  --shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 8px 32px rgba(0, 0, 0, 0.08);
  --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.12);
  --radius-minimal: 10px;
  --radius-sm: 8px;
  --radius-xs: 6px;
  /* The pill step: a radius large enough that a short control reads as
     rounded end to end. Epic 1's pagers and chips each wrote it as a literal;
     this is the declaration they should have referenced. */
  --radius-pill: 20px;
  --transition: var(--transition-property) var(--duration-0-3) var(--ease-standard);
  --bottom-nav-height: 68px;
  --sticky-add-height: 64px;
  --checkout-steps-height: var(--space-40);
  --header-height: 56px;

  /* ---- Warm neutral ramp: surfaces, hairlines and borders ----
     Ordered light to dark; pick the next step up for more contrast. */
  --neutral-50: #fafaf8;
  --neutral-75: #f6f4f0;
  --neutral-100: #f5f3ef;
  --neutral-200: #ece8e0;
  --neutral-300: #e8e5df;
  --neutral-350: #e2ddd3;
  --neutral-400: #d9d4cc;
  --neutral-450: #d8d3ca;
  --neutral-500: #c8c4bc;
  --neutral-550: #c0bdb8;
  --neutral-600: #b8b4ac;

  /* ---- Plain grey ramp: disabled text, icons, placeholder chrome ---- */
  --gray-200: #eee;
  --gray-250: #d7d8d8;
  --gray-300: #ccc;
  --gray-350: #bbb;
  --gray-375: #aaa;
  --gray-400: #b0b0b0;
  --gray-450: #8f8f8f;
  --gray-500: #888;
  --gray-550: #8a7f85;
  --gray-600: #777;
  --gray-700: #666;
  --ink-900: #111;
  --ink-950: #1a1618;
  --black-pure: #000;

  /* ---- Status colour ---- */
  --danger: #e74c3c;
  --danger-dark: #c0392b;
  --danger-deep: #d9481f;
  --danger-bg: #fff6f5;
  --success: #0f9d68;
  --success-alt: #12b981;
  --success-dark: #1e8e3e;
  --success-light: #86efac;
  --success-bg: #e8f6ec;
  --warning-bg: #ffefc2;
  --gold-dark: #9a7b00;
  --gold-deep: #d08a00;
  --cream: #eed5ad;

  /* ---- Translucent colour ----
     Named for their base and their alpha, so a component author
     picks the overlay strength without inventing an rgba(). */
  --black-a04: rgba(0, 0, 0, 0.04);
  --black-a05: rgba(0, 0, 0, 0.05);
  --black-a06: rgba(0, 0, 0, 0.06);
  --black-a08: rgba(0, 0, 0, 0.08);
  --black-a10: rgba(0, 0, 0, 0.1);
  --black-a12: rgba(0, 0, 0, 0.12);
  --black-a16: rgba(0, 0, 0, 0.16);
  --black-a18: rgba(0, 0, 0, 0.18);
  --black-a20: rgba(0, 0, 0, 0.2);
  --black-a28: rgba(0, 0, 0, 0.28);
  --black-a30: rgba(0, 0, 0, 0.3);
  --black-a32: rgba(0, 0, 0, 0.32);
  --black-a35: rgba(0, 0, 0, 0.35);
  --black-a40: rgba(0, 0, 0, 0.4);
  --black-a45: rgba(0, 0, 0, 0.45);
  --black-a58: rgba(0, 0, 0, 0.58);
  --black-a60: rgba(0, 0, 0, 0.6);
  --black-a62: rgba(0, 0, 0, 0.62);
  --black-a68: rgba(0, 0, 0, 0.68);
  --black-a70: rgba(0, 0, 0, 0.7);

  --white-a05: rgba(255, 255, 255, 0.05);
  --white-a08: rgba(255, 255, 255, 0.08);
  --white-a10: rgba(255, 255, 255, 0.1);
  --white-a12: rgba(255, 255, 255, 0.12);
  --white-a15: rgba(255, 255, 255, 0.15);
  --white-a16: rgba(255, 255, 255, 0.16);
  --white-a45: rgba(255, 255, 255, 0.45);
  --white-a55: rgba(255, 255, 255, 0.55);
  --white-a62: rgba(255, 255, 255, 0.62);
  --white-a70: rgba(255, 255, 255, 0.7);
  --white-a75: rgba(255, 255, 255, 0.75);
  --white-a85: rgba(255, 255, 255, 0.85);
  --white-a92: rgba(255, 255, 255, 0.92);

  /* Ink is --primary-black (10, 10, 10) with an alpha. */
  --ink-a05: rgba(10, 10, 10, 0.05);
  --ink-a08: rgba(10, 10, 10, 0.08);
  --ink-a12: rgba(10, 10, 10, 0.12);
  --ink-a15: rgba(10, 10, 10, 0.15);
  --ink-a35: rgba(10, 10, 10, 0.35);
  --ink-a60: rgba(10, 10, 10, 0.6);
  --ink-a65: rgba(10, 10, 10, 0.65);
  --ink-a68: rgba(10, 10, 10, 0.68);
  --ink-a80: rgba(10, 10, 10, 0.8);
  --ink-a88: rgba(10, 10, 10, 0.88);
  --ink-a90: rgba(10, 10, 10, 0.9);
  --ink-a92: rgba(10, 10, 10, 0.92);
  --ink-a97: rgba(18, 18, 18, 0.97);

  /* Yellow is --yellow-accent (232, 184, 0) with an alpha. */
  --yellow-a00: rgba(232, 184, 0, 0);
  --yellow-a06: rgba(232, 184, 0, 0.06);
  --yellow-a10: rgba(232, 184, 0, 0.1);
  --yellow-a12: rgba(232, 184, 0, 0.12);
  --yellow-a15: rgba(232, 184, 0, 0.15);
  --yellow-a18: rgba(232, 184, 0, 0.18);
  --yellow-a30: rgba(232, 184, 0, 0.3);
  --yellow-a40: rgba(232, 184, 0, 0.4);
  --yellow-a45: rgba(232, 184, 0, 0.45);
  --yellow-a65: rgba(232, 184, 0, 0.65);
  --yellow-a85: rgba(232, 184, 0, 0.85);
  --yellow-hover-a95: rgba(245, 197, 24, 0.95);
  --yellow-hover-a100: rgba(245, 197, 24, 1);

  --danger-a12: rgba(231, 76, 60, 0.12);
  --danger-a60: rgba(231, 76, 60, 0.6);
  --success-a10: rgba(16, 185, 129, 0.1);
  --success-a12: rgba(16, 185, 129, 0.12);
  --success-light-a00: rgba(134, 239, 172, 0);
  --success-light-a30: rgba(134, 239, 172, 0.3);
  --cream-a00: rgba(238, 213, 173, 0);
  --cream-a40: rgba(238, 213, 173, 0.4);
  --pink-a00: rgba(236, 72, 153, 0);
  --pink-a07: rgba(236, 72, 153, 0.07);
  --blue-a00: rgba(37, 99, 235, 0);
  --blue-a07: rgba(37, 99, 235, 0.07);

  /* ---- Type scale ----
     Derived from the sizes the prototypes already use. Each step
     is named for its own value in rem (since 30-16 the value is the
     nearest of the 8 steps below; the name is the original size), so a size is chosen by
     reading it rather than by consulting a table. */
  --text-0-5: 0.625rem;
  --text-0-52: 0.625rem;
  --text-0-55: 0.625rem;
  --text-0-58: 0.625rem;
  --text-0-6: 0.625rem;
  --text-0-62: 0.625rem;
  --text-0-65: 0.625rem;
  --text-0-66: 0.625rem;
  --text-0-68: 0.625rem;
  --text-0-7: 0.75rem;
  --text-0-72: 0.75rem;
  --text-0-73: 0.75rem;
  --text-0-74: 0.75rem;
  --text-0-75: 0.75rem;
  --text-0-76: 0.75rem;
  --text-0-78: 0.75rem;
  --text-0-8: 0.75rem;
  --text-0-8125: 0.85rem;
  --text-0-82: 0.85rem;
  --text-0-83: 0.85rem;
  --text-0-84: 0.85rem;
  --text-0-85: 0.85rem;
  --text-0-86: 0.85rem;
  --text-0-88: 0.85rem;
  --text-0-9: 0.85rem;
  --text-0-92: 0.85rem;
  --text-0-95: 1rem;
  --text-1: 1rem;
  --text-1-05: 1rem;
  --text-1-1: 1rem;
  --text-1-2: 1.25rem;
  --text-1-25: 1.25rem;
  --text-1-3: 1.25rem;
  --text-1-35: 1.25rem;
  --text-1-4: 1.5rem;
  --text-1-45: 1.5rem;
  --text-1-5: 1.5rem;
  --text-1-55: 1.5rem;
  --text-1-6: 1.5rem;
  --text-1-7: 1.7rem;
  --text-1-9: 2rem;
  --text-2: 2rem;
  --text-2-15: 2.15rem;
  --text-2-2: 2.2rem;
  --text-2-4: 2.4rem;
  --text-2-5: 2.5rem;
  --text-2-6: 2.6rem;
  --text-3: 3rem;
  --text-3-2: 3.2rem;
  --text-3-5: 3.5rem;

  /* The 8 type steps (Epic 30 / 30-16, PO 2026-10-22). 0.8rem sits exactly
     between 0.75 and 0.85 and rounds DOWN to 0.75. Every --text-N above
     is snapped to one of these. Named exceptions that keep their own value
     because snapping would move them by more than 2px: --text-1-7,
     --text-2-15, --text-2-2, --text-2-4, --text-2-5, --text-2-6, --text-3-2,
     --text-3-5. */
  --text-step-2xs: 0.625rem;
  --text-step-xs: 0.75rem;
  --text-step-sm: 0.85rem;
  --text-step-md: 1rem;
  --text-step-lg: 1.25rem;
  --text-step-xl: 1.5rem;
  --text-step-2xl: 2rem;
  --text-step-3xl: 3rem;

  /* ---- Line-height scale (unitless) ---- */
  --leading-1: 1;
  --leading-1-15: 1.15;
  --leading-1-2: 1.2;
  --leading-1-25: 1.25;
  --leading-1-3: 1.3;
  --leading-1-35: 1.35;
  --leading-1-4: 1.4;
  --leading-1-45: 1.45;
  --leading-1-5: 1.5;
  --leading-1-55: 1.55;
  --leading-1-6: 1.6;
  --leading-1-7: 1.7;

  /* ---- Spacing scale ----
     Derived from the margins, paddings and gaps in use. Named for
     the pixel value they carry. The two rem steps are kept in rem
     because the declarations they replace were authored in rem. */
  --space-1: 1px;
  --space-2: 2px;
  --space-3: 2px;
  --space-4: 4px;
  --space-5: 4px;
  --space-6: 4px;
  --space-7: 8px;
  --space-8: 8px;
  --space-9: 8px;
  --space-10: 8px;
  --space-11: 12px;
  --space-12: 12px;
  --space-13: 12px;
  --space-14: 12px;
  --space-16: 16px;
  --space-18: 16px;
  --space-20: 20px;
  --space-22: 20px;
  --space-24: 24px;
  --space-26: 24px;
  --space-28: 28px;
  --space-30: 28px;
  --space-32: 32px;
  --space-34: 32px;
  --space-36: 36px;
  --space-40: 40px;
  --space-42: 40px;
  --space-48: 48px;
  --space-50: 48px;
  --space-54: 54px;
  --space-56: 56px;
  --space-60: 60px;
  --space-64: 64px;
  --space-90: 90px;
  --space-rem-0-25: 0.25rem;
  --space-rem-0-5: 0.5rem;
  --space-rem-0-6: 0.6rem;
  --space-rem-0-7: 0.7rem;
  --space-rem-1: 1rem;
  --space-rem-1-25: 1.25rem;
  --space-neg-2: -2px;
  --space-neg-8: -8px;
  --space-neg-12: -12px;
  --space-neg-14: -14px;
  --space-neg-24: -24px;
  --space-neg-28: -28px;

  /* The 12 spacing steps (Epic 30 / 30-16, PO 2026-10-22). A value exactly
     between two steps (3, 6, 10, 14, 18, 22, 26, 30px) rounds DOWN, so the
     scale never grows a gap. --space-neg-12 pairs with --space-26 in the header
     nav (24px gap - 12px = the same 12px net the pair always gave). Every --space-N
     above is snapped to one of these. Named exceptions that keep their own
     value because snapping would move them by more than 2px: --space-36,
     --space-54, --space-56, --space-60; --space-1 (hairline) and --space-90
     (layout) are kept by decision; the --space-rem-* and --space-neg-* steps
     are kept as they are. */
  --space-step-2: 2px;
  --space-step-4: 4px;
  --space-step-8: 8px;
  --space-step-12: 12px;
  --space-step-16: 16px;
  --space-step-20: 20px;
  --space-step-24: 24px;
  --space-step-28: 28px;
  --space-step-32: 32px;
  --space-step-40: 40px;
  --space-step-48: 48px;
  --space-step-64: 64px;

  /* ---- Motion ----
     Duration and easing are separate so a component can vary one
     without abandoning the token; --transition above stays as the
     composed convenience the existing usages already reference.
     Every duration collapses under prefers-reduced-motion below,
     which is why no component declares a reduced-motion block. */
  --transition-property: all;
  --duration-0-07: 0.07s;
  --duration-0-25: 0.25s;
  --duration-0-3: 0.3s;
  --duration-0-35: 0.35s;
  --duration-0-4: 0.4s;
  --duration-0-42: 0.42s;
  --duration-0-45: 0.45s;
  --duration-0-5: 0.5s;
  --duration-0-55: 0.55s;
  --duration-0-6: 0.6s;
  --duration-0-7: 0.7s;
  --duration-0-8: 0.8s;
  --duration-1-1: 1.1s;
  --duration-1-4: 1.4s;
  --duration-2-2: 2.2s;
  --duration-9: 9s;
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-back: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-spring-soft: cubic-bezier(0.2, 0.9, 0.3, 1.12);
  --ease-spring: cubic-bezier(0.2, 0.9, 0.3, 1.15);
  --ease-spring-strong: cubic-bezier(0.2, 0.9, 0.3, 1.4);

  /* ---- Breakpoints ----
     The declared set, in one place. CSS cannot evaluate a custom
     property inside a media condition, so these tokens are the
     declaration of record and the DesignTokens tests are what make
     them binding: a media query using any other value fails. */
  --breakpoint-sm-max: 575px;
  --breakpoint-sm: 576px;
  --breakpoint-md-max: 767px;
  --breakpoint-md: 768px;
  --breakpoint-lg-max: 991px;
  --breakpoint-lg: 992px;
  --breakpoint-xl: 1200px;
}

/* Motion collapses once, at the token layer. Every component that
   uses --transition or a --duration-* token inherits this; the
   universal rule below catches keyframe animations authored with a
   literal duration so no component has to opt in individually. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --duration-0-07: 0.01ms;
    --duration-0-25: 0.01ms;
    --duration-0-3: 0.01ms;
    --duration-0-35: 0.01ms;
    --duration-0-4: 0.01ms;
    --duration-0-42: 0.01ms;
    --duration-0-45: 0.01ms;
    --duration-0-5: 0.01ms;
    --duration-0-55: 0.01ms;
    --duration-0-6: 0.01ms;
    --duration-0-7: 0.01ms;
    --duration-0-8: 0.01ms;
    --duration-1-1: 0.01ms;
    --duration-1-4: 0.01ms;
    --duration-2-2: 0.01ms;
    --duration-9: 0.01ms;
  }
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: Arial, sans-serif;
  background-color: var(--white);
  color: var(--text-dark);
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  padding-bottom: var(--bottom-nav-height);
  padding-top: var(--header-height);
}

/* The home page's mobile search bar used to be position:fixed and was
   paid for here by a body.mobile-has-search class. It is sticky now and
   occupies its own height in flow, so this rule would double-count the
   space. Removed rather than left dormant: the class was set through
   OnAfterRenderAsync, which does not run under static SSR, so the rule
   never fired and the bar overlapped the page on every phone. */

@media (min-width: 992px) {
  body {
    padding-bottom: 0;
    padding-top: var(--header-height);
  }
  :root {
    --header-height: 64px;
  }
}

/* Lock scroll while the mobile nav overlay is open. */
body.menu-open {
  overflow: hidden;
}

h1, h2, h3, h4, h5, h6 {
  font-family: 'Poppins', sans-serif;
  font-weight: 600;
}

a {
  color: inherit;
}

img {
  max-width: 100%;
}

button {
  font-family: inherit;
}

/* ===== Scrollbar ===== */
::-webkit-scrollbar {
  width: 5px;
}
::-webkit-scrollbar-track {
  background: var(--off-white);
}
::-webkit-scrollbar-thumb {
  background: var(--neutral-550);
  border-radius: 20px;
}

/* ===== Container ===== */
.container-custom {
  padding-left: var(--space-16);
  padding-right: var(--space-16);
  max-width: 1320px;
  margin: 0 auto;
}
@media (min-width: 768px) {
  .container-custom {
    padding-left: var(--space-24);
    padding-right: var(--space-24);
  }
}
@media (min-width: 1200px) {
  .container-custom {
    padding-left: var(--space-32);
    padding-right: var(--space-32);
  }
}

/* ===== Section headers (shared across sections/pages) ===== */
.section-header {
  margin-bottom: var(--space-28);
}
@media (min-width: 768px) {
  .section-header {
    text-align: left;
    margin-bottom: var(--space-32);
  }
}

/* ===== Checkout step indicator (Epic 30, Story 30-11, 2026-10-22) ===== */
/* STICKY just below the fixed header, ONE compact line at every width, and under
   the header (1050) so the header and its menus always win. */
.checkout-steps {
  position: sticky;
  top: var(--header-height);
  z-index: 1040;
  display: flex;
  align-items: center;
  min-height: var(--checkout-steps-height);
  margin: 0 0 var(--space-16);
  background: var(--white);
  border-bottom: 1px solid var(--neutral-200);
}
/* Story 30-21: the phone only. From the declared tablet breakpoint up the bar
   is not drawn (the markup stays; the script does nothing while it is hidden). */
@media (min-width: 768px) {
  .checkout-steps {
    display: none;
  }
}
.checkout-steps ol {
  display: flex;
  flex-wrap: nowrap;
  gap: var(--space-16);
  list-style: none;
  margin: 0;
  padding: 0;
  counter-reset: checkout-step;
}
.checkout-steps li {
  counter-increment: checkout-step;
}
.checkout-steps a {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  color: var(--text-muted);
  font-size: var(--text-0-85);
  white-space: nowrap;
  text-decoration: none;
  padding: var(--space-6) 0;
}
.checkout-steps a::before {
  content: counter(checkout-step);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--text-1-5);
  height: var(--text-1-5);
  border-radius: var(--radius-pill);
  border: 1px solid var(--neutral-500);
  font-size: var(--text-0-8);
}
.checkout-steps a[aria-current='step'] {
  color: var(--text-dark);
  font-weight: 600;
}
.checkout-steps a[aria-current='step']::before {
  background: var(--yellow-accent);
  border-color: var(--yellow-accent);
  color: var(--primary-black);
}

.section-title {
  font-family: 'Poppins', sans-serif;
  font-weight: 600;
  font-size: var(--text-1-6);
  color: var(--primary-black);
  margin-bottom: var(--space-6);
  letter-spacing: 0.5px;
}
@media (min-width: 768px) {
  .section-title {
    font-size: var(--text-2);
  }
}
.section-subtitle {
  font-size: var(--text-0-85);
  color: var(--text-muted);
  font-weight: 400;
  letter-spacing: 0.3px;
}
/* Clean mobile look: drop descriptive subtitles. */
@media (max-width: 991px) {
  .section-subtitle {
    display: none;
  }
}

/* ===== Breadcrumb (category / detail pages) ===== */
.breadcrumb-line {
  padding: var(--space-24) 0 var(--space-10);
  color: var(--text-muted);
  font-size: var(--text-0-78);
  line-height: var(--leading-1-6);
}
/* Story 30-3 (Epic 30, 2026-10-22): the trail is a nav > ol > li. The list is
   reset so it looks exactly like the line of text it replaced, and the "/"
   between crumbs is drawn here rather than typed between the links. */
.breadcrumb-line ol {
  margin: 0;
  padding: 0;
  list-style: none;
}
.breadcrumb-line li {
  display: inline;
  margin: 0;
  padding: 0;
}
/* Razor trims the whitespace between the <li>, so the separator carries its own
   spaces: " / " exactly as the typed text it replaced rendered. pre keeps the
   spaces from collapsing at the start/end of the pseudo-element. */
.breadcrumb-line li + li::before {
  content: " / ";
  white-space: pre;
}
/* Story 16.2 (moved here by Story 30-3): the Back control above the trail trims
   the trail's own top space. It lives here, not in ItemDetails.razor.css,
   because the nav is rendered by the Breadcrumbs child component and so does
   not carry that page's scope attribute. */
.back-link + .breadcrumb-line {
  padding-top: var(--space-4);
}
.breadcrumb-line a {
  color: var(--text-muted);
  text-decoration: none;
}
.breadcrumb-line strong,
.breadcrumb-line a:hover {
  color: var(--primary-black);
}

/* ===== Ripple (mobile nav taps) ===== */
.ripple {
  position: relative;
  overflow: hidden;
}
.ripple::after {
  content: '';
  position: absolute;
  width: 100%;
  padding-top: 100%;
  border-radius: 50%;
  background: var(--yellow-a30);
  opacity: 0;
  transform: scale(0);
  pointer-events: none;
  transition: all var(--duration-0-4) ease-out;
}
.ripple:active::after {
  opacity: 1;
  transform: scale(2.5);
}

/* ============================================================
   ACCESSIBILITY BASE (Story 1.18)

   Three cross-cutting rules that have to be declared once, at the
   same layer as the reset, because a per-component version of any
   of them is a rule some later component will forget.
   ============================================================ */

/* ---- Focus visibility (WCAG 2.4.7, 1.4.11) ----
   The global ring is transparent by request: no outline colour and no
   coloured halo. Stated rather than deleted, and NOT written as
   `outline: none`, because Windows High Contrast / forced-colors mode
   replaces a transparent outline with a system colour — so a
   high-contrast keyboard shopper still gets a ring, while everyone
   else sees nothing. The offset is kept so that forced ring does not
   sit on top of the control.

   What this costs: on a normal display a keyboard shopper has no
   global indication of where they are. Component rules that declare
   their own :focus-visible outline (the pager, facets, variant and
   quantity controls, add-to-bag, and others in this file) are
   unaffected and still show focus on those controls. */
:focus-visible {
  outline: 3px solid transparent;
  outline-offset: 2px;
}

/* ---- Text available to assistive technology only ----
   Used for a page heading the visual design states another way, and
   for the context a control's visible label leaves implicit. Never
   used to hide something a sighted shopper also needs. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* ---- Bypass block (WCAG 2.4.1) ----
   The header, the mega menus and the mobile nav put dozens of links
   ahead of the page body on every route. The skip link is the first
   thing in the tab order and is off-screen until it is focused. */
.skip-link {
  position: absolute;
  left: var(--space-8);
  top: var(--space-8);
  z-index: 2100;
  transform: translateY(-200%);
  background: var(--primary-black);
  color: var(--white);
  font-family: 'Poppins', sans-serif;
  font-size: var(--text-0-85);
  font-weight: 600;
  padding: var(--space-10) var(--space-16);
  border-radius: var(--radius-sm);
  text-decoration: none;
  transition: transform var(--duration-0-25) var(--ease-standard);
}
.skip-link:focus-visible {
  transform: translateY(0);
}

/* ===== Small utility helpers (used inline in markup, Bootstrap-like) ===== */
.ms-1 { margin-left: var(--space-rem-0-25); }
.ms-2 { margin-left: var(--space-rem-0-5); }
.me-2 { margin-right: var(--space-rem-0-5); }

/* ===== Blazor error UI ===== */
#blazor-error-ui {
  background: var(--warning-bg);
  bottom: 0;
  box-shadow: 0 -1px 2px var(--black-a20);
  display: none;
  left: 0;
  padding: var(--space-rem-0-6) var(--space-rem-1-25) var(--space-rem-0-7) var(--space-rem-1-25);
  position: fixed;
  width: 100%;
  z-index: 3000;
}
#blazor-error-ui .dismiss {
  cursor: pointer;
  position: absolute;
  right: 0.75rem;
  top: 0.5rem;
}
.loading-progress,
.loading-progress-text { display: none; }

/* ===== Mobile vertical rhythm ===== */
/* One consistent, tighter spacing scale under the fixed header across
   every page: breadcrumbs and section headers pull up on mobile. */
@media (max-width: 991px) {
  .breadcrumb-line {
    padding: var(--space-14) 0 var(--space-8);
  }
  .back-link + .breadcrumb-line {
    padding-top: var(--space-2);
  }
  .section-header {
    margin-bottom: var(--space-16);
  }
}
/* =========================================================
   Search typeahead island (Story 2.3)

   Everything the island renders, in one place. It is global rather than scoped
   because the island lives in Odel.Web.Client and carries none of this
   project.s scope attribute; each host adds only what is its own, through
   ::deep.

   It replaced three hand-maintained copies of a dropdown — the header's, the
   mobile bar's and a DesktopSearch component that was instantiated nowhere —
   whose markup had already drifted apart.
   ========================================================= */
/* The field, its controls and the panel. These were written twice — once in
   Header.razor.css and once in MobileSearchBar.razor.css, through ::deep — which
   is precisely the hand-maintained duplication this story set out to remove.
   They live here once; each host overrides only what is genuinely its own, which
   is colour against its own background and where the panel is positioned. */
.typeahead {
  position: relative;
  margin: 0;
}
.typeahead-field {
  display: flex;
  align-items: center;
  gap: var(--space-10);
  border-radius: 25px;
  transition: var(--transition);
}
.typeahead-field i {
  flex-shrink: 0;
}
.typeahead-input {
  flex: 1;
  min-width: 0;
  border: none;
  background: transparent;
  font-family: 'Poppins', sans-serif;
  caret-color: var(--yellow-accent);
}
.typeahead-clear,
.typeahead-submit {
  border: none;
  background: transparent;
  cursor: pointer;
  padding: var(--space-2) var(--space-4);
  flex-shrink: 0;
}
.typeahead-panel {
  position: absolute;
  top: calc(100% + var(--space-8));
  left: 0;
  right: 0;
  background: var(--white);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-minimal);
  box-shadow: var(--shadow-md);
  overflow: hidden;
}
.typeahead-list {
  list-style: none;
  margin: 0;
  padding: var(--space-6);
  max-height: 65vh;
  overflow-y: auto;
}
.typeahead-group-label {
  font-size: var(--text-0-7);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0;
  padding: var(--space-8) var(--space-10) var(--space-4);
}
.typeahead-group-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-8);
}
.typeahead-recent-clear {
  flex: 0 0 auto;
  min-height: var(--space-32);
  padding: 0 var(--space-10);
  border: 0;
  background: none;
  font: inherit;
  font-size: var(--text-0-8);
  color: var(--text-muted);
  text-decoration: underline;
  cursor: pointer;
}
.typeahead-recent-clear:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}
/* The see-all row closes the panel off, so it reads as the way out of the list
   rather than as one more suggestion. */
.typeahead-option.is-seeall {
  font-weight: 600;
  border-top: 1px solid var(--glass-border);
  border-radius: 0;
}
.typeahead-empty {
  padding: var(--space-12) var(--space-10);
  font-size: var(--text-0-85);
  color: var(--text-muted);
}
.typeahead-option {
  display: flex;
  align-items: center;
  gap: var(--space-10);
  width: 100%;
  padding: var(--space-8) var(--space-10);
  border-radius: var(--radius-xs);
  color: var(--text-dark);
  text-decoration: none;
  font-size: var(--text-0-85);
  transition: var(--transition);
}
.typeahead-option:hover,
.typeahead-option:focus-visible,
.typeahead-option.is-active {
  background: var(--subtle-gray);
  color: var(--text-dark);
}
.typeahead-option i {
  color: var(--text-muted);
  flex-shrink: 0;
}
.typeahead-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The kind of place a category suggestion points at — department, category or
   sub-category — so two same-named rows are told apart. */
.typeahead-note {
  font-style: normal;
  font-size: var(--text-0-7);
  color: var(--text-muted);
  flex-shrink: 0;
}
/* A suggestion thumbnail is decorative: the row's own text is the accessible
   name, so the image carries an empty alt rather than repeating it. No price
   rides on it either — suggestions come from the cached names-only corpus,
   which carries no money (Story 1.7). */
.typeahead-thumb {
  width: 36px;
  height: 48px;
  object-fit: cover;
  border-radius: var(--radius-xs);
  background: var(--subtle-gray);
  flex-shrink: 0;
}

/* =========================================================
   Facet panel island (Story 2.4)

   Everything the island renders, in one place. It is global rather than scoped
   for the same reason the typeahead's rules are: the island lives in
   Odel.Web.Client and carries none of this project's scope attribute, so a
   scoped rule could never reach it. The panel's own chrome — the sidebar, the
   phone sheet, the clear link — is scoped, in FacetPanel.razor.css.

   The chip rules below were written out twice before this story, once in
   Categories.razor.css and once in BrandSpec.razor.css, for two sidebars that
   had already drifted apart. They are here once.
   ========================================================= */
.facet-form {
  margin: 0;
}

/* Each dimension is a native disclosure: a <details> with a <summary>, so it
   folds and unfolds with no script, is in the tab order by the browser's own
   rules, and reports its open state to assistive technology without anything
   here claiming it does. A dimension with a value applied is rendered open,
   so an active refinement is never behind a closed heading. */
.facet-section {
  border: none;
  border-bottom: 1px solid var(--neutral-200);
  margin: 0;
  padding: var(--space-11) 0;
}
.facet-section:last-of-type {
  border-bottom: none;
}

.facet-summary {
  display: flex;
  align-items: center;
  gap: var(--space-8);
  cursor: pointer;
  list-style: none;
  /* The marker is drawn below, so the browser's own triangle is removed on
     both engines that have one. */
}
.facet-summary::-webkit-details-marker {
  display: none;
}
/* The chevron is drawn in CSS rather than set in an icon font: it is the only
   thing telling a sighted shopper the heading opens, and a webfont that has
   not loaded would leave that unsaid. */
.facet-summary::after {
  content: "";
  margin-left: auto;
  width: 7px;
  height: 7px;
  border-right: 2px solid var(--text-muted);
  border-bottom: 2px solid var(--text-muted);
  transform: rotate(45deg) translateY(-2px);
  transition: var(--transition);
}
.facet-section[open] > .facet-summary::after {
  transform: rotate(-135deg) translateY(-1px);
}
.facet-summary:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}

.facet-legend {
  padding: 0;
  font-size: var(--text-0-72);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.6px;
}

/* How many of this dimension's values the shopper has ticked. It is the
   shopper's own selection, never a result count — nothing in the gateway
   counts a facet value against a result set. */
.facet-summary-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 18px;
  height: 18px;
  padding: 0 var(--space-5);
  border-radius: var(--radius-pill, 999px);
  background: var(--primary-black);
  color: var(--white);
  font-size: var(--text-0-62, 0.68rem);
}

/* The fieldset keeps the checkboxes a named group; the visible name is the
   summary, so its legend is for assistive technology only. */
.facet-fields {
  border: none;
  margin: 0;
  padding: var(--space-8) 0 0;
}

/* Said where it applies and nowhere else: this dimension's values are the
   whole catalogue's, because nothing can tell the storefront which of them
   have products in these results. */
.facet-note {
  margin: 0 0 var(--space-8);
  font-size: var(--text-0-72);
  line-height: 1.45;
  color: var(--text-muted);
}

/* The rest of a long dimension, behind a second native disclosure. The values
   are still in the document and still in the form. */
.facet-more {
  margin-top: var(--space-8);
}
.facet-more-summary {
  cursor: pointer;
  font-size: var(--text-0-73);
  text-decoration: underline;
  color: var(--text-dark);
  margin-bottom: var(--space-8);
}
.facet-more-summary:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}

.facet-values {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-8);
}
.facet-values > li {
  flex: 0 0 auto;
}

/* The chip is the label and the checkbox is its immediate previous sibling —
   an explicit for/id pair rather than a wrapping label, because that is what
   every assistive technology reads. Clicking the chip therefore toggles the
   input, and the input is visually hidden rather than removed: a control
   nobody can focus is a control half the shoppers here cannot use. */
.chip-label {
  display: inline-block;
  padding: var(--space-5) var(--space-12);
  border: 1px solid var(--neutral-400);
  border-radius: var(--radius-pill, 20px);
  font-size: var(--text-0-73);
  cursor: pointer;
  transition: var(--transition);
  color: var(--text-dark);
  background: var(--white);
}
.chip-checked {
  background: var(--primary-black);
  color: var(--white);
  border-color: var(--primary-black);
}
.facet-check {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
/* The focus ring, drawn on the chip when the hidden input behind it has focus.
   It is an adjacent-sibling rule, not :focus-within: the input is a sibling of
   the label, so :focus-within could never match it and every facet value was
   tabbable but invisible (WCAG 2.4.7). :focus is the fallback for browsers
   without :focus-visible, and it is listed first so the newer rule wins. */
.facet-check:focus + .chip-label,
.facet-check:focus-visible + .chip-label {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}

/* Story 30-13 (Epic 30, 2026-10-22): the ordering inside the phone filter
   sheet. From 992px up the toolbar's select is the control and these radios
   are hidden (they still submit, which is what carries the sort through an
   unscripted Apply). */
.facet-sort {
  border: none;
  margin: 0;
  padding: var(--space-11) 0;
  border-bottom: 1px solid var(--neutral-200);
}
.facet-sort-legend {
  padding: 0;
  font-size: var(--text-0-82);
  font-weight: 600;
  color: var(--text-dark);
}
.facet-sort-option {
  display: flex;
  align-items: center;
  gap: var(--space-8);
  min-height: 44px;
}
.facet-sort-radio {
  margin: 0;
  accent-color: var(--primary-black);
}
.facet-sort-label {
  font-size: var(--text-0-8);
  color: var(--text-dark);
  cursor: pointer;
  flex: 1;
}
@media (min-width: 992px) {
  .facet-sort {
    display: none;
  }
}

/* Present exactly while nothing is driving the form: with no script, and with
   script that has not hydrated the island yet. Once it has, the island stops
   rendering this and a selection takes effect on change. */
.facet-submit {
  margin-top: var(--space-12);
  width: 100%;
  background: var(--primary-black);
  color: var(--white);
  border: none;
  border-radius: var(--radius-minimal);
  padding: var(--space-10) var(--space-16);
  font-family: inherit;
  font-size: var(--text-0-82);
  font-weight: 600;
  cursor: pointer;
}

/* =========================================================
   Sort control island (Story 2.5)

   Everything the island renders, in one place. It is global rather than scoped
   for the reason the facet panel's rules are: the island lives in
   Odel.Web.Client and carries none of this project's scope attribute, so a
   scoped rule could never reach it. The control's own chrome — the toolbar
   group — is scoped, in ListingSortControl.razor.css.

   The pill select is the design system's, from AllProducts.razor's .sort. What
   is not taken from it is the "Apply Filters" button beside it: interactive,
   the ordering takes effect on change, and the submit control below exists only
   while nothing is driving the form.
   ========================================================= */
.sort-form {
  margin: 0;
  display: flex;
  align-items: center;
  gap: var(--space-8);
  flex-wrap: wrap;
}

.sort-label {
  font-size: var(--text-0-73);
  font-weight: 600;
  color: var(--text-dark);
  white-space: nowrap;
}

/* min-height, not padding: the pill computed to about 33px, under the 44px
   target Story 1.18 established for anything a thumb has to hit, and a select
   is the one control here a shopper must hit twice. max-width keeps a long
   option ("Newest first" plus a native picker's own chrome) inside a narrow
   toolbar rather than pushing the count off the row. */
.sort-select {
  border: 1px solid var(--neutral-400);
  padding: var(--space-7) var(--space-12);
  border-radius: var(--radius-pill, 20px);
  background: var(--white);
  font-family: inherit;
  font-size: var(--text-0-8);
  color: var(--text-dark);
  cursor: pointer;
  min-height: 44px;
  max-width: 100%;
}
.sort-select:focus,
.sort-select:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}

/* Present exactly while nothing is driving the form: with no script, and with
   script that has not hydrated and reconciled the island yet. Once it has, the
   island stops rendering this and the ordering takes effect on change. */
.sort-submit {
  background: var(--primary-black);
  color: var(--white);
  border: none;
  border-radius: var(--radius-pill, 20px);
  padding: var(--space-7) var(--space-16);
  font-family: inherit;
  font-size: var(--text-0-8);
  font-weight: 600;
  cursor: pointer;
  min-height: 44px;
}
/* border: none over a dark fill leaves a browser's default ring nothing to draw
   against, and this is the ONLY actuator on the unscripted path — the one a
   keyboard shopper with no script reaches the sort through (WCAG 2.4.7). The
   ring is drawn explicitly, in the colour that reads against the fill. */
.sort-submit:focus,
.sort-submit:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}

/* =========================================================
   Product purchase island — price row (Story 16.10, moved from the
   product page's own scoped ItemDetails.razor.css). A scoped stylesheet is
   attributed only to the markup the OWNING component renders directly, and
   this price row now renders inside ProductPurchaseIsland — a WASM island in
   a separate project — for every dimensioned product; a scoped rule would
   silently stop applying there while still applying to a non-dimensioned
   product's own directly-rendered PriceRow. One global definition, exactly
   the same rule Story 16.8 followed for the swatch/size-btn rules below,
   applies identically to both, and to the island whether it has hydrated or
   not (the prerendered markup is identical either way).
   ========================================================= */

.price-row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-8);
  margin-bottom: var(--space-4);
}
/* Dimmed while the price row shows a stale figure between an instant option
   switch and its background price fetch landing (Story 16.10's own
   PriceRow.Busy). Not opacity on the whole row — see the swatch veil's own
   remarks a little further down for why opacity is the wrong tool whenever a
   focusable/live-updating control sits inside what is being dimmed; a plain
   price row has no such control, so a simple opacity reduction is enough
   here. */
.price-row[aria-busy="true"] {
  opacity: 0.6;
}
/* Story 16.10 review finding #10: scoped under .price-row rather than bare
   .price/.mrp/.discount/.sale-badge — this global stylesheet cascades to
   EVERY page, and a bare class name here would also style any unrelated
   element elsewhere (ProductCard, a listing row) that happens to reuse one
   of these class names, relying only on that OTHER component's own scoped
   CSS carrying enough specificity to win. Scoping under the one wrapper
   every purchase-island/PriceRow price actually renders inside removes the
   ambiguity outright rather than depending on a specificity race. */
/* Each price wraps as a whole item, never at the space between the currency
   and the amount (2026-10-26). */
.price-row .price,
.price-row .mrp,
.price-row .discount {
  white-space: nowrap;
}
.price-row .price {
  font-size: var(--text-1-35);
  font-weight: 800;
  color: var(--primary-black);
}
.price-row .mrp {
  /* WCAG 1.4.3: --gray-450 on white is 3.2:1. */
  color: var(--text-muted);
  text-decoration: line-through;
  font-size: var(--text-0-95);
}
.price-row .discount {
  /* WCAG 1.4.3: --gold-deep on white is 2.9:1; --gold-dark is the same hue
     family at 5.0:1. The saving is a fact about the price, not decoration. */
  color: var(--gold-dark);
  font-size: var(--text-0-9);
  font-weight: 700;
}
.price-row .sale-badge {
  background: var(--yellow-accent, var(--yellow-accent));
  color: var(--primary-black, var(--ink-950));
  font-size: var(--text-0-72);
  font-weight: 700;
  letter-spacing: 0.3px;
  padding: var(--space-3) var(--space-10);
  border-radius: 20px;
}

/* .tax-note is NOT scoped under .price-row: it styles the "Inclusive of all
   taxes" line, which renders as .price-row's own SIBLING (inside
   ProductPurchaseIsland's root and inside ItemDetails.razor's non-dimensioned
   branch alike), never inside it. It stays a bare global rule, exactly as it
   was in the scoped stylesheet this block replaced.

   It used to also style a "Product code: …" line rendered right beside the
   tax note in both places. That line is gone (Story 17.4, Product Owner,
   2026-10-01: remove the product-code label) — .tax-note keeps its one
   remaining job rather than being deleted, since the tax note itself still
   renders. */
.tax-note {
  color: var(--text-muted);
  font-size: var(--text-0-8);
  margin-bottom: var(--space-16);
}

/* =========================================================
   Variant selector island (Story 3.2; rebuilt on swatches and size
   buttons by Story 11.5 / 11.6). Global rather than scoped, for the same
   reason the sort control's rules are: VariantSelectorIsland lives in
   Odel.Web.Client and carries none of Odel.Web's scope attribute, so a
   rule in VariantSelector.razor.css would never reach the controls the
   island renders.
   ========================================================= */
.variant-selector h2 {
  font-size: var(--text-0-9);
  font-weight: 700;
  margin-bottom: var(--space-8);
}

/* Colour and Size stack one below another rather than side by side
   (2026-09-30 request); each .variant-dimension below still wraps its own
   swatches/size buttons across the row it occupies. */
.variant-form {
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
  align-items: stretch;
}

.variant-dimension {
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
}

.variant-label {
  font-size: var(--text-0-75);
  font-weight: 600;
  color: var(--text-dark);
}

/* The selected value's own name, appended to the dimension heading ("Colour:
   Blue", Story 16.8). Muted rather than the heading's own weight, so "Blue"
   reads as the answer to "Colour:" rather than as a second heading. */
.variant-label-value {
  font-weight: 400;
  color: var(--text-muted);
}

/* ---- colour swatches (Story 11.5) --------------------------------- */

.swatch-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-8);
}

/* A circle in the colour itself. min-width/min-height rather than a fixed
   width/height keep the 44px target consistent across all three fill modes
   below. */
/* 56px since Story 12.12, up from 44px. 44 was chosen as the minimum
   comfortable pointer target; a colour swatch is also the thing a Shopper is
   trying to SEE, and at 44px a subtle shade reads as a dot rather than a
   colour. The 44px floor is still cleared with room to spare. */
/* Three fill modes (Story 16.8), set through CSS custom properties rather
   than an inline `background` shorthand — the shorthand is what used to wipe
   out the disabled state's diagonal strike below, because both painted
   through the same `background` property and only the last one written
   could win. `background-color`/`background-image` are two independent
   properties, so the swatch's own fill and the disabled strike (now a
   `::after`) can never collide again, on any of the three modes:
   `.swatch-hex` (--swatch-fill), `.swatch-photo` (--swatch-image, cropped
   like a slot image) and `.swatch-plain` (neither — a neutral grey circle,
   with no visible text, since there is nothing to fall back to naming). */
.swatch {
  box-sizing: border-box;
  position: relative;
  min-width: 56px;
  min-height: 56px;
  border-radius: 999px;
  border: 1px solid var(--neutral-400);
  background-color: var(--swatch-fill, var(--neutral-400));
  background-image: var(--swatch-image, none);
  background-size: cover;
  background-position: center;
  cursor: pointer;
  padding: 0;
  transition: var(--transition);
}
.swatch:hover {
  border-color: var(--yellow-accent);
}
.swatch:focus,
.swatch:focus-visible {
  outline: 3px solid var(--primary-black);
  outline-offset: 2px;
}

/* The selected swatch is told apart by more than its own colour (a black
   ring reads against every hue this storefront's colours span, including
   white and black themselves) — never by colour alone. This ring is drawn on
   every fill mode identically, because it is a border/shadow pair rather
   than anything the fill itself could interfere with. */
.swatch.selected {
  border: 3px solid var(--primary-black);
  box-shadow: 0 0 0 2px var(--white) inset;
}

/* A value that is still clickable but should read as dimmed (Story 16.7's
   Reason alongside Selectable=true): out of stock, or not sold alongside
   what else is chosen. A translucent veil painted OVER the fill via ::before
   — never `opacity` on the whole element. `opacity` composites the entire
   element onto a single see-through layer, which silently faded the focus
   outline and the selected ring's box-shadow past a usable contrast right
   along with the fill (Story 16.8 review). The veil is an absolutely
   positioned child inset from the border, so the border, the focus outline
   (drawn outside the border box) and the selected ring (an inset box-shadow,
   white under a white veil) all stay at full strength underneath it,
   whatever this value's own state.
   Never the never-sold veil below — a shopper who can still buy it needs to
   be able to tell the two apart at a glance. */
.swatch[data-availability]::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--white-a45);
  pointer-events: none;
}

/* aria-disabled, never the native attribute (VariantSelectorIsland's own
   doc: a fully disabled control drops out of the accessibility tree and a
   screen reader could never reach its reason). The veil (this rule's own
   ::before, same non-opacity technique as the dimmed rule above, and for the
   same reason) is the sighted equivalent of that reason; the diagonal strike
   is drawn in its own ::after rather than in either rule's
   background-image, precisely so it never collides with --swatch-image or
   --swatch-fill above (the bug this story's own spec names: an inline
   `background` used to wipe out the gradient strike on a hex swatch).
   ::after paints on top of both fill layers and the veil, regardless of
   which fill is in play. */
.swatch[aria-disabled="true"] {
  cursor: not-allowed;
}
.swatch[aria-disabled="true"]::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--white-a55);
  pointer-events: none;
}
/* A light halo either side of the dark line, so the strike still reads over
   a dark hex or photo fill (Story 16.8 review: the bare dark line at low
   opacity disappeared against black/navy). The halo is white regardless of
   fill, which still reads over a light fill too — the dark line itself is
   what carries the mark there, the halo only what makes it visible
   everywhere else. */
.swatch[aria-disabled="true"]::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background-image: linear-gradient(
    to top right,
    transparent calc(50% - 3px),
    var(--white) calc(50% - 2px),
    var(--text-dark) calc(50% - 1px),
    var(--text-dark) calc(50% + 1px),
    var(--white) calc(50% + 2px),
    transparent calc(50% + 3px)
  );
}

/* ---- size buttons (Story 11.6) ------------------------------------- */

.size-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-8);
}

/* Same box every other control on this page draws (Checkout's
   .delivery-details select, the account card's inputs): a bordered,
   off-white control at the 44px target, and the yellow focus/active
   language the rest of the page uses. */
.size-btn {
  border: 1px solid var(--neutral-400);
  border-radius: 8px;
  padding: var(--space-11) var(--space-14);
  font-family: inherit;
  font-size: var(--text-0-85);
  color: var(--text-dark);
  background: var(--off-white);
  cursor: pointer;
  min-height: 44px;
  min-width: 44px;
  transition: var(--transition);
}
.size-btn:hover {
  border-color: var(--yellow-accent);
}
.size-btn:focus,
.size-btn:focus-visible {
  border-color: var(--yellow-accent);
  background: var(--white);
  box-shadow: 0 0 0 3px var(--yellow-a10);
}
.size-btn.selected {
  border: 2px solid var(--primary-black);
  background: var(--primary-black);
  color: var(--white);
}

/* A size that is still clickable but should read as dimmed (out of stock, or
   not sold alongside the chosen colour) — a dashed border, and a lighter
   fade than the never-sold state below, so the two remain visually distinct
   even without reading the text. */
.size-btn[data-availability] {
  opacity: 0.5;
  border-style: dashed;
}

/* aria-disabled, never the native attribute — see .swatch[aria-disabled]
   above; the same reasoning applies to every gated control this selector
   renders. A lower opacity than the dimmed-but-clickable rule above (0.4 vs
   0.5, Story 16.8 review): a size button carries its own visible text rather
   than a bare fill, so the never-sold state has the line-through to tell it
   apart even at a glance — but the two opacity figures should still differ
   in the same direction the swatch's two veils do, rather than by
   coincidence read as identical. */
.size-btn[aria-disabled="true"] {
  opacity: 0.4;
  cursor: not-allowed;
  text-decoration: line-through;
}

/* CHOSEN AND CLOSED (Story 23.19). The two states used to simply stack, giving
   a solid black, struck-through chip that still read as "this is your size" at
   a glance — the Product Owner reported exactly that on 2026-10-09.

   It can no longer happen by DEFAULT: VariantMatrix.BestSelectionFor now fills
   a dimension only from a purchasable combination. It can still happen when an
   address NAMES a sold-out variant, which is kept on purpose — a shopper
   following a link to one size must see that size, with its reason, not be
   moved silently onto another.

   So the selection is stated as an outline rather than a fill: the ring says
   "this is the one you asked for", the strike and the fade say it cannot be
   bought, and neither contradicts the other. Opacity is NOT set here — the
   0.4 from the rule above still applies and must keep its documented distance
   from the 0.5 dimmed-but-clickable state, which VariantSwatchStyleTests
   pins. */
.size-btn.selected[aria-disabled="true"] {
  background: var(--white);
  color: var(--primary-black);
  border: 2px solid var(--primary-black);
}

/* =========================================================
   Add-to-cart island (Story 4.3)

   Global rather than scoped, for the same reason the typeahead's rules are: the
   island lives in Odel.Web.Client and carries none of this project's scope
   attribute, so ItemDetails.razor.css's .primary-btn — which is written as
   .primary-btn[b-xxxxxxxx] — does not reach it. The button's own appearance is
   therefore restated here rather than inherited, and the two are deliberately
   the same shape: one control on the page, two stylesheets, and a shopper who
   should not be able to tell.
   ========================================================= */
.add-to-cart {
  display: grid;
  gap: var(--space-9);
  align-content: start;
}

.add-to-bag {
  border: none;
  border-radius: 4px;
  padding: var(--space-14) var(--space-18);
  font-family: inherit;
  font-weight: 800;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  transition: var(--transition);
  cursor: pointer;
  background: var(--primary-black);
  color: var(--white);
  min-height: 44px;
  width: 100%;
}
.add-to-bag:hover:not([aria-disabled="true"]) {
  background: var(--yellow-accent);
  color: var(--primary-black);
}
.add-to-bag:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}

/* aria-disabled, never the native attribute — the same rule every gated
   control on this page follows (.swatch[aria-disabled], .size-btn[aria-
   disabled] above). This is the one that shipped as a defect: the WASM
   island this control used to be rendered `disabled="@(!Offered ||
   _inFlight)"`, which drops the button from the accessibility tree with no
   explanation reachable from it, and — combined with a runtime that could
   never finish starting — is why "Add to Bag never becomes usable" was
   reported at all. This control is a native form post now (AddToCart.razor's
   own header): it is aria-disabled for exactly one reason, the gate saying
   no, and the sentence why is always the note right below it. */
.add-to-bag[aria-disabled="true"] {
  cursor: not-allowed;
  opacity: 0.55;
}

/* Story 17.4. This USED TO SAY the control "has no in-flight state to show" —
   true of Blazor (there genuinely is no client-side render between a press
   and the reload; AddToCart.razor's own header pins this with a live
   Playwright trace), but not true of the CONTROL, which can show one with no
   Blazor at all: pdp-variant.js adds `.is-submitting` and `aria-busy="true"`
   on a plain DOM `submit` event, which fires on every browser regardless of
   whether any framework ever starts.

   The spinner is drawn in ::after rather than replacing the label's own
   text, so the label a screen reader already announced is never swapped out
   from under aria-busy — the busy state is conveyed by the attribute, not by
   rewritten content. The label itself is dimmed (opacity) rather than hidden,
   which is a plain style and needs no reduced-motion fork of its own. The
   button is deliberately NOT given the native `disabled` attribute anywhere
   in this story either (pdp-variant.js's own remarks): a disabled control's
   value is not submitted, which would turn the very press this spinner is
   confirming into one the server never sees.

   The spin's OWN reduced-motion handling is the token layer's existing
   universal rule just above (the `*, *::before, *::after` block inside the
   one `@media (prefers-reduced-motion: reduce)` this file is allowed —
   ComponentStylesheetTests.ExactlyOneReducedMotionBlockExistsAndItIsInThe-
   TokenLayer enforces there is only ever one), which already forces
   `animation-iteration-count: 1` and collapses every `animation-duration` to
   0.01ms on EVERY element, this pseudo-element included — exactly why the
   duration below is the shared `--duration-0-8` token (Loader.razor.css's own
   `asyncSpin` sets the precedent) rather than a literal: a literal duration
   would still animate at full length once, where the token collapses with
   everything else the system already collapses. One real rotation that
   finishes near-instantly reads as "static ring", not as a spin — which is
   the fade-rather-than-spin behaviour this story asked for, achieved by
   reusing the system's own collapse instead of writing a second one. */
.add-to-bag.is-submitting {
  cursor: progress;
  position: relative;
  opacity: 0.6;
}
.add-to-bag.is-submitting::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border: 2px solid var(--neutral-200);
  border-top-color: var(--white);
  border-radius: 50%;
  animation: odel-add-to-bag-spin var(--duration-0-8) linear infinite;
}

@keyframes odel-add-to-bag-spin {
  to {
    transform: rotate(360deg);
  }
}

/* THE PRODUCT PAGE'S SMALL WAIT (Product Owner, 2026-10-13).

   "when pdp size selection dont pop the loader, instead put a small circle
   loader next right to the add item button."

   Picking a size is what starts the WebAssembly runtime, and that start used
   to raise the full-screen overlay — over the product the Shopper was reading,
   repeatedly, as they tried sizes. The overlay is for going somewhere else.
   This is for waiting here.

   Revealed by loading-overlay.js (data-busy-inline on the variant form names
   this element) and hidden again by every path that clears the overlay. The
   row exists only to seat it beside the button: the button keeps the full
   width it had, and the circle takes the space to its right, so the button
   does not move when the circle appears or goes.

   It reuses odel-add-to-bag-spin rather than declaring a second identical
   rotation — the two spinners on this page should turn at the same rate, and
   it takes its duration from --duration-0-8 so the token layer's one
   prefers-reduced-motion block collapses it with everything else. No
   reduced-motion block of its own: the tokens own that, and exactly one such
   block may exist (ComponentStylesheetTests). */
.add-to-bag-row {
  display: flex;
  align-items: center;
  gap: var(--space-10);
}
.add-to-bag-row .add-to-bag {
  flex: 1 1 auto;
}
.add-to-bag-busy {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  border: 2px solid var(--neutral-200);
  border-top-color: var(--primary-black);
  border-radius: 50%;
  animation: odel-add-to-bag-spin var(--duration-0-8) linear infinite;
}
/* `hidden` is the state this element ships in, and an element with a
   display rule of its own would otherwise ignore it. */
.add-to-bag-busy[hidden] {
  display: none;
}

/* Story 30-9 (Epic 30, 2026-10-22): the phone's sticky Add to Bag bar
   (Components/Cart/StickyAddBar.razor). Hidden unless pdp-variant.js has set
   data-shown (main button off screen), and only at 767px and below. It sits
   just above MobileBottomNav (z-index 1060) at --bottom-nav-height, and while
   shown the body class lifts the floating cart shortcut above it. */
.sticky-add-bar {
  display: none;
}
@media (max-width: 767px) {
  .sticky-add-bar[data-shown] {
    position: fixed;
    left: 0;
    right: 0;
    bottom: var(--bottom-nav-height);
    z-index: 1050;
    box-sizing: border-box;
    min-height: var(--sticky-add-height);
    display: flex;
    align-items: center;
    gap: var(--space-12);
    padding: var(--space-10) var(--space-16);
    background: var(--glass-bg);
    backdrop-filter: blur(22px);
    -webkit-backdrop-filter: blur(22px);
    border-top: 1px solid var(--glass-border);
    box-shadow: 0 -4px 20px var(--black-a06);
  }
  /* The fixed bar covers the last stretch of the page; its height joins the
     body's bottom padding while it is up so the page's end stays reachable. */
  body.has-sticky-add {
    padding-bottom: calc(var(--bottom-nav-height) + var(--sticky-add-height));
  }
  body.has-sticky-add .cart-fab {
    bottom: calc(var(--bottom-nav-height) + var(--sticky-add-height) + var(--space-16));
  }
}

/* The floating "Back to top" button (Components/Chrome/BackToTop.razor,
   2026-10-26). Bottom right, where the cart shortcut also lives: alone it
   takes the shortcut's spot, and when the shortcut is on the page (it comes
   earlier in document order) it stacks one step above it. On a phone it sits
   above the bottom nav, and above the sticky Add to Bag bar while that is up.
   Hidden until back-to-top.js adds .is-visible, a screen or so down the page;
   visibility as well as opacity so a hidden button is not focusable. */
.back-to-top {
  position: fixed;
  right: var(--space-16);
  bottom: calc(var(--bottom-nav-height) + var(--space-16));
  z-index: 1040;
  width: var(--space-48);
  height: var(--space-48);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--primary-black);
  color: var(--white);
  font-size: var(--text-1-1);
  cursor: pointer;
  box-shadow: 0 8px 24px var(--black-a35);
  opacity: 0;
  visibility: hidden;
  transform: translateY(var(--space-12));
  transition: var(--transition);
}
.back-to-top.is-visible {
  opacity: 1;
  visibility: visible;
  transform: none;
}
.back-to-top:hover {
  color: var(--yellow-accent);
}
.back-to-top:focus-visible {
  outline: 2px solid var(--yellow-accent);
  outline-offset: 2px;
}
.cart-fab ~ .back-to-top {
  bottom: calc(var(--bottom-nav-height) + var(--space-16) + var(--space-60));
}
@media (max-width: 767px) {
  body.has-sticky-add .back-to-top {
    bottom: calc(var(--bottom-nav-height) + var(--sticky-add-height) + var(--space-16));
  }
  body.has-sticky-add .cart-fab ~ .back-to-top {
    bottom: calc(var(--bottom-nav-height) + var(--sticky-add-height) + var(--space-16) + var(--space-60));
  }
}
@media (min-width: 992px) {
  .back-to-top {
    right: var(--space-24);
    bottom: var(--space-24);
  }
  .cart-fab ~ .back-to-top {
    bottom: calc(var(--space-24) + var(--space-60));
  }
}
.sticky-add-bar-name {
  flex: 1 1 auto;
  min-width: 0;
  font-weight: 700;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sticky-add-bar-btn {
  flex: 0 0 auto;
  border: none;
  border-radius: 4px;
  padding: var(--space-12) var(--space-18);
  font-family: inherit;
  font-weight: 800;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  cursor: pointer;
  background: var(--primary-black);
  color: var(--white);
  min-height: 44px;
}
.sticky-add-bar-btn:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}
.sticky-add-bar-btn[aria-disabled="true"] {
  cursor: not-allowed;
  opacity: 0.55;
}
.sticky-add-bar-btn.is-submitting {
  cursor: progress;
  position: relative;
  opacity: 0.6;
}


.add-to-cart-note {
  margin: 0;
  font-size: var(--text-0-84);
  color: var(--text-dark);
}

.add-to-cart-accepted,
.add-to-cart-blocker,
.add-to-cart-refused,
.add-to-cart-degraded {
  margin: 0 0 var(--space-8);
  font-size: var(--text-0-84);
  line-height: 1.5;
}

.add-to-cart-accepted i {
  color: var(--success-dark);
  margin-right: var(--space-8);
}
.add-to-cart-figures {
  display: block;
  color: var(--text-dark);
}
.add-to-cart-link {
  display: inline-block;
  margin-top: var(--space-7);
  font-weight: 700;
  text-decoration: underline;
}

/* A blocker rides on a success, so it cannot read like a failure — and it cannot
   read like a merchandising fact either. It is the shopper's own bag telling
   them something is wrong with a line in it. */
.add-to-cart-blocker {
  color: var(--gold-dark);
}
.add-to-cart-blocker i {
  margin-right: var(--space-8);
}

/* A refusal: the request was understood and declined. */
.add-to-cart-refused {
  color: var(--danger-dark);
}

/* A degradation: OUR problem, not the product's. Set apart from the refusal
   above deliberately — "we could not reach your bag" and "this cannot be added"
   are different claims and must not look like the same notice. */
.add-to-cart-degraded {
  color: var(--text-dark);
  background: var(--neutral-100);
  border-left: 3px solid var(--neutral-400);
  padding: var(--space-8) var(--space-10);
}
.add-to-cart-degraded i {
  margin-right: var(--space-8);
}

/* =========================================================
   Cart line controls island (Story 4.5)

   Global rather than scoped, for the same reason the add-to-cart island's rules
   are: the island lives in Odel.Web.Client and carries none of Odel.Web's scope
   attribute, so Checkout.razor.css's .qty-control[b-xxxxxxxx] could never reach
   it. The rules the cart page used to own are restated here rather than
   inherited, in the same shape — one control on the page, two stylesheets, and
   a shopper who should not be able to tell.
   ========================================================= */
/* One row: the stepper and the remove button sit side by side, because they
   are the two things that can be done to this line and reading them as a
   single group is the point. */
.cart-line-controls {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--space-12);
  flex-wrap: wrap;
}
@media (max-width: 575px) {
  .cart-line-controls {
    width: 100%;
  }
}

.qty-control {
  display: flex;
  align-items: center;
  border: 1px solid var(--neutral-400);
  border-radius: 8px;
  overflow: hidden;
}
.qty-control button {
  background: none;
  border: none;
  min-width: 44px;
  min-height: 44px;
  font-weight: 700;
  font-size: var(--text-1);
  cursor: pointer;
  transition: var(--transition);
  color: var(--text-dark);
}
.qty-control button:hover:enabled {
  background: var(--subtle-gray);
}
/* The controls are inside a border-less group over a light fill, so a browser's
   default ring has little to draw against. It is drawn explicitly, and inset so
   the rounded group does not clip it (WCAG 2.4.7). */
.qty-control button:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: -2px;
}
/* Disabled has to be visible, not merely inert: a bound that is only announced
   to the handler is a control a sighted shopper presses and learns nothing
   from. The reason itself is the note below. */
.qty-control button:disabled {
  cursor: not-allowed;
  opacity: 0.45;
}

/* The quantity is a readout of the server's figure, not an input. It is sized
   like the box it replaced so the group keeps its shape. */
.qty-value {
  display: inline-block;
  min-width: 44px;
  text-align: center;
  border-left: 1px solid var(--neutral-400);
  border-right: 1px solid var(--neutral-400);
  padding: var(--space-8) var(--space-4);
  font-weight: 600;
  font-size: var(--text-0-85);
  background: var(--white);
}

/* A quantity change or removal in flight (Product Owner, 2026-10-29): the
   figure gives way to a small spinning circle, the product card's own loader,
   instead of an "Updating your bag…" sentence. The figure stays in the box,
   only transparent, so the stepper keeps its width. Reduced motion is covered
   globally. */
.qty-value.is-updating {
  position: relative;
  color: transparent;
}
.qty-value.is-updating::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--space-14);
  height: var(--space-14);
  box-sizing: border-box;
  border-radius: 50%;
  border: 2px solid var(--gray-200);
  border-top-color: var(--primary-black);
  animation: qty-value-spin var(--duration-0-8) linear infinite;
}
@keyframes qty-value-spin {
  from {
    transform: translate(-50%, -50%) rotate(0deg);
  }
  to {
    transform: translate(-50%, -50%) rotate(360deg);
  }
}

.remove-btn {
  background: none;
  border: none;
  color: var(--danger-dark);
  cursor: pointer;
  font-size: var(--text-0-85);
  font-weight: 600;
  transition: var(--transition);
  white-space: nowrap;
  min-height: 44px;
  padding: 0;
}
.remove-btn i {
  margin-right: var(--space-4);
}
.remove-btn:hover:enabled {
  color: var(--danger);
}
.remove-btn:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}
.remove-btn:disabled {
  cursor: not-allowed;
  opacity: 0.45;
}

/* In the cart drawer (Product Owner, 2026-10-27): a smaller stepper and an
   icon-only remove button on the same row, so each line takes less height.
   The button keeps its accessible name ("Remove {item}", aria-label); only
   the visible word goes. The /cart page keeps the full-size controls. */
.drawer .cart-line-controls {
  flex-wrap: nowrap;
  gap: var(--space-8);
}
.drawer .qty-control {
  border-radius: var(--radius-xs);
}
.drawer .qty-control button {
  min-width: var(--space-32);
  min-height: var(--space-32);
  font-size: var(--text-0-85);
}
.drawer .qty-value {
  min-width: var(--space-32);
  padding: var(--space-4) var(--space-2);
  font-size: var(--text-0-8);
}
.drawer .remove-btn {
  min-width: var(--space-32);
  min-height: var(--space-32);
  font-size: var(--text-1);
}
.drawer .remove-btn i {
  margin-right: 0;
}
.drawer .remove-btn .remove-label {
  display: none;
}

/* A control that is not offered, or an island that is not live yet, says why
   right here.

   NONE OF THESE NOTES IS SHOWN ANY MORE (Product Owner, 2026-09-27: "still
   cart-line-note is coming make it display none"). The pre-hydration sentence
   went on 2026-09-24; this takes the rest — the notes that say why a - or a +
   button is disabled.

   CLIPPED, NOT display:none, and the distinction is the whole reason this
   comment is long. The quantity buttons point at these paragraphs with
   aria-describedby, and content in a display:none element is not announced at
   all — so a Shopper using a screen reader would meet a disabled control with
   no explanation beside it, which is precisely the state these notes were
   written to prevent. Clipping is visually identical: the note is gone from
   the page for the eye, and still read out for anyone who needs to know why
   the button will not move.

   If these are ever wanted back, this block is the only thing standing between
   them and the page. */
.cart-line-note {
  margin: 0;
  max-width: 28ch;
  font-size: var(--text-0-75);
  color: var(--text-dark);

  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* The pre-hydration sentence ("These load on your first press…") is not shown
   (Product Owner, 2026-09-24). Only that one: `.cart-line-note` is also the
   class on the notes that say why a − or + button is DISABLED, and the island's
   own comment records that a disabled control with no reason beside it is the
   one thing that story was not allowed to ship. Those stay visible.

   Taken out of the visual flow rather than out of the document, with the same
   properties as `.sr-only` instead of `display: none`, for two reasons: the
   quantity button points at this paragraph's id with aria-describedby, and
   display:none content is not announced — so a Shopper using a screen reader
   would meet a control that is not yet live with no explanation at all, which
   is the state the visible note existed to prevent. A sighted Shopper loses a
   line of noise; nobody loses the information. */
.cart-line-note.not-ready {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.cart-line-busy,
.cart-line-accepted,
.cart-line-refused,
.cart-line-degraded {
  margin: 0;
  max-width: 28ch;
  font-size: var(--text-0-75);
  line-height: 1.5;
}

/* "Updating your bag…" and "Quantity updated." are read out but not shown
   (Product Owner, 2026-10-29: "that update message dont show"). The spinner in
   .qty-value is what the eye gets. Clipped rather than display:none so the
   live region still announces them. */
.cart-line-busy,
.cart-line-accepted {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* A refusal: the request was understood and declined. */
.cart-line-refused {
  color: var(--danger-dark);
}

/* A degradation: OUR problem, not the line's. Deliberately set apart from the
   refusal above — "we could not reach your bag" and "you cannot have this" are
   different claims and must not look like the same notice. */
.cart-line-degraded {
  color: var(--text-dark);
  background: var(--neutral-100);
  border-left: 3px solid var(--neutral-400);
  padding: var(--space-8) var(--space-10);
}
.cart-line-degraded i {
  margin-right: var(--space-8);
}

/* An in-flight control must not present itself as ready, and neither must one
   whose island has not started. */
.cart-line-controls button[aria-busy="true"] {
  opacity: 0.7;
}

/* ===== Fulfilment: how the Shopper wants to receive this order (4.6) ===== */
/* Global rather than scoped, because the island lives in Odel.Web.Client and a
   scoped stylesheet belongs to the component that declares it. Same reason the
   cart line's controls are styled here. */
.fulfilment-control {
  display: grid;
  gap: var(--space-8);
  margin: var(--space-16) 0;
  padding-top: var(--space-16);
  border-top: 1px solid var(--neutral-200);
}

.fulfilment-legend {
  margin: 0;
  font-size: var(--text-0-85);
  font-weight: 600;
  color: var(--text-dark);
}

.fulfilment-options {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-8);
}
@media (max-width: 575px) {
  .fulfilment-options {
    grid-template-columns: 1fr;
  }
}

.fulfilment-option {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-8);
  min-height: 44px;
  padding: var(--space-10) var(--space-12);
  background: var(--white);
  border: 1px solid var(--neutral-400);
  border-radius: 8px;
  font-size: var(--text-0-82);
  font-weight: 600;
  color: var(--text-dark);
  cursor: pointer;
  transition: var(--transition);
}
.fulfilment-option:hover:enabled {
  background: var(--subtle-gray);
}
.fulfilment-option:focus-visible {
  outline: 2px solid var(--primary-black);
  outline-offset: 2px;
}

/* The selected arm, drawn so it is not conveyed by colour alone — the border
   thickens and the fill changes together (WCAG 1.4.1). aria-checked is what
   says it to assistive technology, and this is the same fact drawn. */
.fulfilment-option[aria-checked="true"] {
  border-color: var(--primary-black);
  border-width: 2px;
  background: var(--subtle-gray);
}

/* Unavailable has to be visible AND still readable, which is the whole premise
   of this control: a control that is not offered still has to teach. The 0.45
   this started at put the label at roughly 2.2:1 on the card fill, below the
   4.5:1 floor — a control nobody can read teaches nothing, and dimming it into
   illegibility is the visual equivalent of hiding it. So the label keeps a
   contrasting colour of its own and the fill carries the unavailability, with
   the dashed border saying it a second way for anyone who cannot see the
   difference in tone (WCAG 1.4.1, 1.4.3). The reason itself is the note below,
   tied to this button by aria-describedby rather than by sitting near it. */
.fulfilment-option[aria-disabled="true"] {
  cursor: not-allowed;
  background: var(--neutral-100);
  border-style: dashed;
  color: var(--text-muted);
}

/* Genuinely inert, and only ever for an in-flight change. */
.fulfilment-option:disabled {
  cursor: progress;
  opacity: 0.7;
}

/* An in-flight control must not present itself as ready, and neither must one
   whose island has not started. */
.fulfilment-control button[aria-busy="true"] {
  opacity: 0.7;
}

.fulfilment-note,
.fulfilment-busy,
.fulfilment-accepted,
.fulfilment-refused,
.fulfilment-degraded {
  margin: 0;
  font-size: var(--text-0-75);
  line-height: 1.5;
  color: var(--text-dark);
}

/* A merchandising fact is drawn as an ordinary statement about the bag. */
.fulfilment-note-not-eligible {
  color: var(--text-muted);
}

/* A backend failure is OUR problem and is set apart from the merchandising note
   above — "we could not check this" and "these items cannot be collected" are
   different claims and must not look like the same notice.

   It is also set apart from .fulfilment-degraded below, which used to be given
   the identical treatment. Those two are a third pair of different claims: this
   one says a question about the items could not be answered and the order will
   be delivered, and that one says a change the Shopper just asked for did not
   happen. A Shopper acts differently on each — one is information, the other is
   a retry — so a warm fill and a gold rule mark the standing condition, while
   the failed action below keeps the neutral block the cart line's degradation
   uses. */
.fulfilment-note-check-failed {
  background: var(--warning-bg);
  border-left: 3px solid var(--gold-dark);
  padding: var(--space-8) var(--space-10);
}

/* Scripting is unavailable: a standing statement about the page, not about the
   bag and not about a failed action. Plain, because nothing is wrong. */
.fulfilment-note-no-script {
  color: var(--text-muted);
}

/* THE NOTES THE PRODUCT OWNER TOOK OFF THE PAGE (2026-09-27): the pre-press
   note, the delivery note, and the backend-failure sentence about a pickup
   check. Same treatment, same reasoning and the same trade as
   `.cart-line-note.not-ready` above — out of the visual flow, still in the
   document, because each of these paragraphs is an aria-describedby target on
   a control that is busy or aria-disabled.

   WRITTEN OUT HERE RATHER THAN LEFT TO `.sr-only` ALONE, and that is the whole
   point of this rule existing. `.sr-only` and `.fulfilment-note` have equal
   specificity, so which one wins is decided by source order — and
   `.fulfilment-note-check-failed` further up sets a padding and a background
   that `.sr-only` never anticipated. A rule that hides something only because
   it happens to be declared in the right place is a rule that stops hiding it
   the day someone moves a block. This one carries two classes and wins
   outright. */
.fulfilment-note.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
  background: none;
}

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

.fulfilment-accepted {
  color: var(--success-dark);
}

.fulfilment-refused {
  color: var(--danger-dark);
}

.fulfilment-degraded {
  background: var(--neutral-100);
  border-left: 3px solid var(--neutral-400);
  padding: var(--space-8) var(--space-10);
}
.fulfilment-degraded i {
  margin-right: var(--space-8);
}

/* ---- the cart badge (Story 4.7) ----------------------------------------
   One appearance, defined once, for both placements.

   The badge is placed twice — in the header and in the phone's bottom nav —
   and it is the same island in both. Its look therefore cannot live in either
   placement's scoped stylesheet: two copies drift, and the first pair did,
   one growing with a second digit while the other kept a fixed circle that a
   two-digit count spilled out of, in two different colours. What stays scoped
   is only where the pill sits, which is genuinely different in the two bars.

   It is global rather than scoped because the markup is the island's, in
   Odel.Web.Client, and carries neither project's scope attribute.

   min-width with horizontal padding rather than a fixed width: a bag can hold
   ten things, and a pill that clips its own number is worse than no pill. */
.cart-badge .badge {
  position: absolute;
  min-width: 17px;
  height: 17px;
  padding: 0 var(--space-3);
  background: var(--primary-black);
  /* Story 23.4: the count itself is the brand yellow, on the black pill.
     #e8b800 on #0a0a0a carries about 10:1, well clear of 4.5:1 for text this
     small. Only the NUMBER changes colour -- the pill, its white ring and the
     unknown state below are untouched, so the badge still reads as the same
     object and a "?" still cannot be mistaken for a figure. */
  color: var(--yellow-accent);
  font-family: 'Poppins', sans-serif;
  font-size: var(--text-0-6);
  font-weight: 700;
  line-height: 1;
  border-radius: 9px;
  border: 2px solid var(--white);
  display: flex;
  align-items: center;
  justify-content: center;

  /* The badge sits over the link it belongs to; a press on it is a press on
     the link, not a dead spot in the middle of the target. */
  pointer-events: none;
}

/* Story 30-6 (Epic 30, 2026-10-22): the badge pulses once when the count
   moves. A separate rule from the pill above on purpose. Reduced motion is the
   one block in the token layer, which collapses every animation. */
@keyframes odel-badge-pulse {
  0% { transform: scale(1); }
  50% { transform: scale(1.25); }
  100% { transform: scale(1); }
}

.cart-badge .badge[data-pulse] {
  animation: odel-badge-pulse var(--duration-0-45) var(--ease-back);
}

/* The bag could not be read. Deliberately not the count pill's colour: a
   shopper who glances at either bar must not read an unknown state as a
   number. --white on --text-muted carries 4.8:1. */
.cart-badge .badge-unknown {
  background: var(--text-muted);
  color: var(--white);
}

/* ===== The submit button (button-styles CSS pass) ========================
   .submit-btn is a plain HTML <button>: every page and checkout panel that
   uses the class relied on `.account-card .submit-btn` for its actual chrome,
   so a form outside the account card — the checkout page's own "Save
   delivery details" and "Switch to delivery" buttons, the loyalty panel's
   "Confirm", the guest order lookup's submit — rendered as an unstyled
   browser default button. This is the class's own base: colour, border,
   radius, weight and motion, with no width set, because most uses (the
   per-line Update/Remove/Apply/Remove buttons the checkout panels each carry
   their own `.submit-btn.secondary` copy for) sit inline beside other
   controls rather than filling a row. `.account-card .submit-btn` below adds
   only the width and size an account form's single button wants, and each
   panel's own `.submit-btn.secondary` rule still supplies its quieter
   colour — both now inherit this padding, radius and weight instead of
   having none. */
.submit-btn {
  border: none;
  border-radius: 8px;
  padding: var(--space-10) var(--space-18);
  font-family: inherit;
  font-size: var(--text-0-85);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  cursor: pointer;
  transition: var(--transition);
  background: var(--primary-black);
  color: var(--white);
}
.submit-btn:hover {
  background: var(--yellow-accent);
  color: var(--primary-black);
}
.submit-btn[aria-disabled='true'],
.submit-btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

/* ===== The account card (Story 5.5) =====================================
   One card, once. Sign-in and register, the registration confirmation
   landing, the mobile verification step and both password-recovery surfaces
   are the same panel: a centred column, a bordered white card at most 440px
   wide, a heading, a paragraph of explanation, labelled inputs and one full
   width button. Each of them had its own copy of it, and each copy's comment
   said so — "the same card the sign-in and register forms live in" — which is
   a duplication that documents itself rather than one that hides.

   Global rather than scoped because it belongs to no single page. A page opts
   in by adding `account-card` to the wrapper it already has, and keeps its own
   scoped stylesheet for what is genuinely its own: the sign-in tabs, the terms
   checkbox, the resend countdown. Those load after this file, so a page that
   needs to differ still wins on source order rather than by restating the
   whole card to change one value.                                        */
.account-card {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: calc(100vh - var(--header-height) - 100px);
  padding: var(--space-40) 0;
}

.account-card .card {
  background: var(--white);
  border-radius: var(--radius-minimal);
  box-shadow: var(--shadow-lg);
  border: 1px solid var(--neutral-200);
  width: 100%;
  max-width: 440px;
  padding: var(--space-30) var(--space-24);
}

.account-card .card h1,
.account-card .card h2 {
  font-size: var(--text-1-1);
  font-weight: 700;
  color: var(--text-dark);
  margin-bottom: var(--space-12);
}

.account-card .card p {
  font-size: var(--text-0-85);
  color: var(--text-muted);
  margin-bottom: var(--space-16);
  line-height: 1.5;
}

.account-card .form {
  display: block;
}

.account-card .group {
  margin-bottom: var(--space-16);
}
.account-card .group label {
  font-size: var(--text-0-8);
  font-weight: 600;
  color: var(--text-dark);
  margin-bottom: var(--space-4);
  display: block;
}
.account-card .group input:not([type='checkbox']),
.account-card .group select {
  width: 100%;
  border: 1px solid var(--neutral-400);
  border-radius: 8px;
  padding: var(--space-11) var(--space-14);
  font-size: var(--text-0-85);
  font-family: inherit;
  background: var(--off-white);
  color: var(--text-dark);
  transition: var(--transition);
}
.account-card .group input:not([type='checkbox']):focus,
.account-card .group select:focus {
  border-color: var(--yellow-accent);
  background: var(--white);
  box-shadow: 0 0 0 3px var(--yellow-a10);
}

/* The rule stated before submission rather than revealed by rejection, so it
   has to be readable rather than merely present. */
.account-card .hint {
  display: block;
  margin-top: var(--space-4);
  font-size: var(--text-0-78);
  color: var(--text-muted);
}

/* Full-width and slightly larger for the one-button account forms; the shared
   chrome (colour, border, radius, weight, cursor, motion) now comes from the
   plain .submit-btn rule below, so this is only what an account form's button
   does differently from the button everywhere else. */
.account-card .submit-btn {
  width: 100%;
  padding: var(--space-14);
  font-size: var(--text-0-9);
}

/* The ordinary ways on from a card: sign in, ask again, start shopping. */
.account-card .routes {
  margin-top: var(--space-16);
  font-size: var(--text-0-85);
  color: var(--text-muted);
}
.account-card .routes a,
.account-card .check-email a {
  color: var(--primary-black);
  font-weight: 600;
}

.account-card .check-email-address {
  font-weight: 600;
  color: var(--text-dark);
  word-break: break-all;
}

/* Uniform mobile page rhythm. */
@media (max-width: 991px) {
  .account-card {
    padding: var(--space-24) 0;
  }
}

/* ---- the loading screen (Story 22-2) ------------------------------------ */

/* Shown while the WebAssembly runtime starts on a shopper's FIRST touch of an
   island, and while an enhanced navigation is in flight. Both are waits the
   storefront previously spent entirely silent.

   z-index 3000 matches .state.fullscreen and #blazor-error-ui, the two
   existing top-layer surfaces, so this sits above the mobile nav (1080) and
   the promo popup (2000) rather than under whichever one happens to be open.

   [hidden] does the hiding, so the element is inert for a shopper with no
   JavaScript — js/loading-overlay.js is the only thing that reveals it. */
.odel-loading {
  position: fixed;
  inset: 0;
  z-index: 3000;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-16);
  background: var(--ink-a92);
  backdrop-filter: blur(6px);
}

.odel-loading[hidden] {
  display: none;
}

/* The mark and the ring share one box so the ring is concentric with the logo
   rather than sitting beside it: the logo is centred in the grid cell and the
   ring is drawn around the same centre. */
.odel-loading-mark {
  display: grid;
  place-items: center;
}

.odel-loading-mark > * {
  grid-area: 1 / 1;
}

/* A background rather than an <img>: see the note in App.razor. The white
   wordmark is the only logo asset committed, and it reads correctly on this
   dark scrim. */
.odel-loading-logo {
  width: 104px;
  height: 42px;
  background-image: url("/img/odel-logo-white.png");
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
}

/* The ring is deliberately larger than the wordmark so it orbits it. The
   duration is a TOKEN, not a literal: app.css carries exactly one
   prefers-reduced-motion block, in the token layer, and a literal duration
   would escape it and keep spinning for a shopper who asked it not to
   (ComponentStylesheetTests pins that block's uniqueness). */
.odel-loading-spinner {
  width: 148px;
  height: 148px;
  border-radius: 50%;
  border: 2px solid var(--white-a12);
  border-top-color: var(--yellow-accent);
  animation: odel-loading-spin var(--duration-0-8) linear infinite;
}

.odel-loading-text {
  margin: 0;
  color: var(--white);
  font-size: var(--text-0-85);
  letter-spacing: 0.04em;
}

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

/* ---- the page skeletons (Story 30-7, Epic 30, 2026-10-22) ---------------- */

/* A navigation to a listing or a product page used to cover the page with the
   spinner above. Every page is statically rendered and waits for its data
   before the response starts, so a skeleton cannot be streamed in; instead
   js/loading-overlay.js reveals one of these server-rendered templates in the
   spinner's place, on the same 150ms delay, the same hide events and the same
   15s ceiling. Any other navigation still gets #odel-loading.

   ONE MECHANISM, MANY TEMPLATES. A template is a hidden `.odel-skeleton`
   element in App.razor carrying data-skeleton="{name}" and an id, and an entry
   in the TEMPLATES table at the top of loading-overlay.js that says which
   addresses it stands in for. Story 30-8 adds the product page the same way:
   one element, one table row, and its own rules below. Nothing else changes.

   It starts BELOW the header (the header is the one thing that is already
   right, and its links keep working) and sits at the overlay's z-index, so it
   outranks the mobile nav and the promo popup exactly as the spinner does.
   [hidden] hides it, so a shopper with no script never meets one.

   COLOURS AND SPACING ARE TOKENS. Block heights are sizes, not spacing steps. */
.odel-skeleton {
  position: fixed;
  top: var(--header-height);
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 3000;
  overflow: hidden;
  background: var(--white);
}

.odel-skeleton[hidden] {
  display: none;
}

/* Below 992px MobileBottomNav is on screen; the skeleton ends above it
   rather than covering it. */
@media (max-width: 991px) {
  .odel-skeleton {
    bottom: var(--bottom-nav-height);
  }
}

/* The one shimmer. The gradient is twice the box wide and slides across it; the
   keyframes END on the resting position, so under prefers-reduced-motion (one
   iteration, 0.01ms, in the token layer) the block lands static. The duration is
   a token so that block also reaches it. */
.skeleton {
  background: linear-gradient(
    90deg,
    var(--neutral-100) 25%,
    var(--neutral-200) 50%,
    var(--neutral-100) 75%
  );
  background-size: 200% 100%;
  background-position: 0 0;
  animation: odel-skeleton-shimmer var(--duration-1-4) ease-in-out infinite;
}

@keyframes odel-skeleton-shimmer {
  from {
    background-position: 100% 0;
  }
  to {
    background-position: 0 0;
  }
}

.skeleton-bar {
  height: 0.85rem;
  border-radius: var(--radius-xs);
}

.skeleton-bar-short {
  width: 40%;
}

.skeleton-bar-medium {
  width: 70%;
}

/* ---- the listing template ------------------------------------------------ */

/* The listing frame: the same container, the same 240px refinement column at
   >=992 and the same gap as the listing pages' .content grid. */
.skeleton-listing-frame {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-24);
  padding-top: var(--space-16);
}

.skeleton-listing-side {
  display: none;
}

.skeleton-listing-products {
  min-width: 0;
}

.skeleton-listing-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-10);
  margin-bottom: var(--space-16);
}

.skeleton-listing-toolbar .skeleton-bar {
  width: 30%;
}

.skeleton-listing-grid {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-10);
}

/* The skeleton card's box is ProductCard's `.item` box: the same flex-basis at
   the same three widths, the same border and radius, a 2/3 image area and a
   body padded --space-12. The flex-basis values are DUPLICATED here because
   ProductCard's are component-scoped; SkeletonListingTests pins the two sets
   equal so they cannot drift silently. */
.skeleton-card {
  flex: 0 0 calc(50% - 5px);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: 1px solid var(--gray-200);
  border-radius: var(--radius-minimal);
  background: var(--white);
}

.skeleton-card-image {
  aspect-ratio: 2 / 3;
}

.skeleton-card-body {
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
  padding: var(--space-12);
}

@media (min-width: 768px) {
  .skeleton-card {
    flex: 0 0 calc(25% - 8px);
  }
}

@media (min-width: 992px) {
  .skeleton-card {
    flex: 0 0 calc(20% - 8px);
  }

  .skeleton-listing-frame {
    grid-template-columns: 240px 1fr;
  }

  .skeleton-listing-side {
    display: flex;
    flex-direction: column;
    gap: var(--space-16);
  }

  .skeleton-listing-side .skeleton-bar {
    height: 1.5rem;
  }
}

/* ---- the product template (Story 30-8, Epic 30, 2026-10-22) -------------- */

/* The product page's frame. ItemDetails.razor.css is component-scoped, so these
   values are DUPLICATED here (not moved: Epic29MobileGalleryAndColourTapTests
   read that file) and SkeletonProductTests pins the two sets equal.
   .layout: one column, gap --space-28, padding --space-8 0 --space-36; at
   >=992 minmax(0, 1.02fr) minmax(360px, 0.98fr) with gap --space-42. The
   gallery is two 2/3 halves side by side at <=767, one 2/3 block from 768, and
   at >=992 a 67x100 thumbnail column beside the hero (Story 30-24, 2026-10-23:
   2/3, the shape of the product photos, was 3/4). */
.skeleton-product-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-28);
  padding: var(--space-8) 0 var(--space-36);
}

.skeleton-product-gallery {
  display: flex;
  flex-direction: column;
  gap: var(--space-10);
  min-width: 0;
}

.skeleton-product-rail {
  display: none;
}

.skeleton-product-hero {
  display: flex;
  gap: 0;
  min-width: 0;
}

/* Full-bleed pair at <=767 (2026-10-23), as ItemDetails.razor.css: two halves,
   no gap, no radius, the gallery cancelling the container's phone padding. */
.skeleton-product-image {
  flex: 0 0 50%;
  aspect-ratio: 2 / 3;
  border-radius: 0;
}

.skeleton-product-thumb {
  flex: 0 0 auto;
  width: 67px;
  height: 100px;
  border-radius: var(--radius-minimal);
}

.skeleton-product-info {
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
  padding-top: var(--space-18);
  min-width: 0;
}

.skeleton-product-title {
  width: 85%;
  height: 1.5rem;
}

.skeleton-product-price {
  height: 1.25rem;
}

.skeleton-product-selectors {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-8);
}

.skeleton-product-swatch {
  width: 32px;
  height: 32px;
  border-radius: var(--radius-pill);
}

.skeleton-product-size {
  width: 56px;
  height: 40px;
  border-radius: var(--radius-minimal);
}

.skeleton-product-button {
  display: block;
  width: 100%;
  height: 48px;
  border-radius: var(--radius-minimal);
}

@media (max-width: 767px) {
  .skeleton-product-gallery {
    margin-inline: calc(-1 * var(--space-16));
  }
}

@media (min-width: 768px) {
  .skeleton-product-image {
    flex: 1 1 auto;
    border-radius: var(--radius-minimal);
  }

  .skeleton-product-image-second {
    display: none;
  }
}

@media (min-width: 992px) {
  .skeleton-product-layout {
    grid-template-columns: minmax(0, 1.02fr) minmax(360px, 0.98fr);
    gap: var(--space-42);
    align-items: start;
  }

  .skeleton-product-gallery {
    flex-direction: row;
    align-items: flex-start;
  }

  .skeleton-product-rail {
    display: flex;
    flex-direction: column;
    gap: var(--space-8);
    flex: 0 0 auto;
  }

  .skeleton-product-hero {
    flex: 1 1 auto;
  }
}

/* THE PRINTED BILL (Story 27.4). Product Owner, 2026-10-15: "there should be a
   order bill export function". The decision was the browser's own Print / Save
   as PDF over a print-only layout — no PDF library, no server endpoint — so this
   is where "the bill" is made. There was no print rule anywhere in the
   storefront before it.

   GLOBAL because what has to go is the site's own chrome, which no order
   component owns: the header, the footer, both navigations, the account tabs,
   the toast host, the skip link and the demo banner, the breadcrumb, and every
   button (the bill's own included). The order's own print layout lives with the
   order, in Components/Orders/OrderView.razor.css.

   THE PAGE HEADINGS, THE PAYMENT SECTION AND THE WAY BACK ARE HIDDEN TOO: the
   page h1 ("Your order") would print above the bill's own head, and the payment
   claim ("we cannot confirm this payment yet ... do not pay again") is a note to
   a Shopper on a screen, not a line of a bill. The bill's own head leads.

   "WHAT THIS PAGE CANNOT TELL YOU" IS HIDDEN TOO: it is a note to a Shopper on a
   screen about this storefront's own limits, and does not belong on a bill.

   BLACK ON WHITE, no background images, and nothing here computes a figure: the
   bill prints exactly what the order page states. */
@media print {
  .header,
  .footer,
  nav,
  .skip-link,
  .demo-mode-banner,
  .toast-region,
  .breadcrumb-line,
  .cart-fab,
  .back-to-top,
  .order-confirmation-gap,
  .order-confirmation-away,
  .order-confirmation-heading,
  .order-confirmation-payment,
  .order-detail-heading,
  .payment-return-heading,
  .payment-return-body,
  .payment-return-unread,
  .payment-return-away,
  [data-toast-region],
  button,
  .submit-btn {
    display: none !important;
  }

  html,
  body {
    background: var(--white) !important;
    background-image: none !important;
    color: var(--primary-black) !important;
  }

  main {
    margin: 0 !important;
    padding: 0 !important;
  }
}
