/* ============================================================================
   Nova Market — stylesheet

   Section order: tokens → base → layout → navbar → buttons → hero → company
   strip → products (toolbar, grid, card) → modal → cart → auth card → form
   fields → features → features (collapsible) → footer → responsive.
   Every section is wrapped in matching BEGIN/END banners.

   Bump the ?v= query on the <link> in index.html after editing this file —
   browsers cache local CSS aggressively, even on a hard reload.
   ============================================================================ */


/* ==== BEGIN: Design tokens ================================================= */

:root {
  /* Sage bands — the navbar and footer share one colour, so it lives in a
     single token both point at. */
  --surface-band: #6f9f6f;
  --surface-nav: var(--surface-band);
  --surface-footer: var(--surface-band);

  /* Creamy-white main section, with two supporting surfaces stepped off it:
     --card sits above the page (raised), --bg-soft below it (inset). */
  --bg: #f8f5ec;             /* main section — creamy white */
  --card: #fffdf7;           /* raised cards — warm white   */
  --bg-soft: #edefe4;        /* inset fields, media boxes   */
  --border: #d7dfd2;         /* hairlines — sage-tinted     */

  /* Ink, not white: this is a light theme. Both clear AA on cream (13.4:1)
     and on the sage bands (4.8:1). */
  --text: #1d2b20;
  --text-dim: #5a6b5c;

  /* Accents. --accent-2 and --danger are darkened from typical dark-theme
     values, which fail contrast as text on cream (1.7:1 and 2.5:1).
     --accent-2 doubles as the company-name colour on product cards. */
  --accent: #6c5ce7;
  /* Second stop of the primary button's gradient. It is a token because a
     company landing page overrides --accent with its own colour, and a
     hardcoded purple second stop then clashes with every one of them. */
  --accent-lift: #8b7cf0;
  --accent-2: #0a7a68;
  /* A FIGURE THIS SHOP INVENTED rather than one the manufacturer published —
     pack size, trade or wholesale, marked per field by 026's three flags.

     WHY A NEW HUE AND NOT AN EXISTING ONE. It had to be distinct from all four
     colours already carrying meaning here: --accent purple is "the primary
     action", --accent-2 teal is already "a term of sale" (see .product-compare),
     --danger red is "this failed or is destructive", and the sunset is "pay on
     delivery". A defaulted price is none of those — it is not an error and not
     an offer, it is a placeholder waiting for someone to correct it — so
     reusing any of them would have made two different things the same colour.

     MEASURED: #8a2f6b on --card is 7.65:1, which clears AA for normal text with
     room and very nearly AAA, so the figure stays as readable as the ones
     around it. Deliberately NOT red: these products are orderable and their
     prices are real charges, so colouring them like a failure would say the
     wrong thing to a customer looking at the face page. */
  --provisional: #8a2f6b;
  --danger: #c0392b;
  --star: #b5811f;           /* rating glyph — warm gold */

  /* Sunset yellow — the navbar monogram badge, gold falling to orange. Warm
     against the sage band rather than tinted from it, which is the point: the
     badge is the one place on the bar that is not a shade of green.

     --brand-ink carries BOTH the "SP" monogram and the wordmark beside it, and
     it is a token because the two have to stay the same ink — a monogram a
     shade off its own wordmark reads as a rendering fault.

     Measured by repainting this gradient to a canvas at the badge's real 38px,
     masking it to the glyphs and reading the pixels actually behind ink — not
     from the stop values, which are optimistic: the glyphs sit in the middle of
     the sweep and never touch the pale corner. Worst glyph pixel 7.4:1, best
     9.0:1, so the monogram clears AAA on every pixel of the plate. The wordmark
     is 5.4:1 on the sage band, against the 4.8:1 plain --text was giving it.

     There is no footer plate any more — the footer heading is the name alone,
     so the navbar's 38px badge is now the only place this gradient carries a
     glyph. The rule the footer used to keep is still worth stating, because it
     is what made resizing the plate free: glyph and box scaled together, so the
     type landed on the same part of the sweep and 7.4:1 / 9.0:1 held without
     re-measuring. That does not generalise — .btn-sunset resizes the box
     WITHOUT the type and its numbers move, see the note there.

     Cream ink — what the badge used over its old purple — measures 1.8:1 on this
     plate and is unreadable. The ink flips dark exactly because the plate went
     light; the two cannot be changed independently. */
  --sunset-1: #ffc24b;
  --sunset-2: #f9a337;
  --sunset-3: #f2802a;
  --brand-ink: #14220f;

  /* -- Fulfilment states -----------------------------------------------------
     The admin order table's dropdown, asked for on 2026-08-23: red while
     Processing, sunset yellow once Shipped, green once Delivered.

     THE INK IS PAIRED WITH THE PLATE AND FLIPS BETWEEN THEM. Measured contrast
     against 12.8px text, which needs 4.5:1:

       #c0392b on white ink ... 5.44:1  PASS
       #ffc24b on dark ink .... 10.33:1 PASS
       #2e7d4f on white ink ... 5.05:1  PASS

     One ink for all three is what makes this look easy and is exactly the trap:
     white on #ffc24b measures 1.61:1, which is not text. Dark ink on #c0392b is
     3.05:1 — legal for headings, not for this. So the pairs travel together, the
     same rule the sunset tokens above keep.

     GREEN IS A NEW TOKEN rather than a reuse. accent-2 (#0a7a68) is the nearest
     existing colour and measures 5.25:1, but it is a teal that reads as brand
     furniture next to the sage nav; #2e7d4f is unambiguously "done". */
  --fulfil-processing: #c0392b;
  --fulfil-processing-ink: #ffffff;
  --fulfil-shipped: #ffc24b;
  --fulfil-shipped-ink: var(--brand-ink);
  --fulfil-delivered: #2e7d4f;
  --fulfil-delivered-ink: #ffffff;

  /* -- The account plate, by gender ------------------------------------------
     Asked for on 2026-09-14: "pink color for a female user and sunset yellow
     for a male user". Collected at sign-up (GENDERS in app.jsx, mirrored by the
     backend and by migration 030's CHECK) and applied by genderPlateClass().

     SUNSET YELLOW IS THE TOKEN THAT ALREADY MEANS IT — --sunset-1, aliased
     rather than re-typed, the --surface-nav rule. It is NOT --fulfil-shipped,
     which is the same hex: that one means "this order has shipped", and
     pointing a second meaning at it would be the reuse --provisional's note
     forbids. Two names, one hue, because one is a ramp stop and the other is a
     meaning.

     FLAT, NOT THE BRAND GRADIENT. The gradient's ratios can only be had by
     rasterising the composited stack (see the note above), and a 44px circle
     whose ink sits dead centre would need re-measuring on every resize. A flat
     plate is one exact number. It also keeps the two plates comparable to each
     other, which a gradient and a flat colour would not be.

     THE PINK IS NEW, and it has to answer the two that exist. --provisional
     (#8a2f6b) already means "a figure this shop invented" and is a plum dark
     enough to need pale ink; --hero-magenta (#ff9ecb) is a banner copy colour
     scoped to .hero-banner and was measured as INK on a dark panel, never as a
     surface. Neither is a light plate for dark ink, which is what this needs.
     #f7b2cd was chosen for weight as much as hue: its relative luminance is
     0.560 against --sunset-1's 0.604, so the two plates carry the same visual
     weight on the bar — a pink markedly darker or lighter would read as one
     gender being emphasised over the other.

     Measured, ink on plate, against 13.1px bold text — which is NOT WCAG large
     (18.66px bold is the bar), so 4.5:1 applies and not 3:1:

       --brand-ink on #f7b2cd ... 9.64:1  PASS, clears AAA
       --brand-ink on #ffc24b ... 10.33:1 PASS, clears AAA

     AND THE EDGE, which is where this change actually costs something. The
     cream plate it replaces measures 3.01:1 against the sage band — just over
     WCAG 1.4.11's 3:1 for a UI component's boundary. Both coloured plates are
     well under it: #ffc24b is 1.90:1 on the band and #f7b2cd is 1.78:1. That
     is stated rather than glossed, because it is a real regression and the
     reason the rules below swap .btn's --border hairline for a --brand-ink
     ring, which measures 5.42:1 on the band and restores the boundary with
     room the cream plate never had.

     NOT A COLOUR-ONLY MESSAGE, so 1.4.1 is not engaged: the plate still prints
     the customer's initials, the button still carries their name in its
     aria-label, and the hue reflects something they entered about themselves
     rather than anything they have to act on. For the same reason no claim is
     made that pink and sunset are separable under deuteranopia — they are
     close in hue there, and nothing depends on telling them apart. */
  --gender-female: #f7b2cd;
  --gender-female-ink: var(--brand-ink);
  --gender-male: var(--sunset-1);
  --gender-male-ink: var(--brand-ink);

  --radius: 14px;
  --shadow: 0 12px 32px rgba(28,46,32,0.10);

  /* Fallback height of the fixed navbar, used before JS runs. The
     useFixedNavHeight hook measures the real height on mount and on resize
     and writes it to this property inline on <html>, which wins over this
     rule — so editing the value here only affects the first paint. */
  --nav-h: 70px;

  /* -- The page column ----------------------------------------------------
     THE FOUR NUMBERS BELOW DECIDE THE WHOLE WIDE-SCREEN LAYOUT, and they are
     here rather than beside .product-grid because the catalogue is what sets
     the page's measure: above 1400px the container is DERIVED from the width
     four cards want, so the navbar, hero, showcase, company strip, catalogue
     and footer all sit on one column with one pair of margins. See "The page
     column on a wide screen" in the responsive section.

     --card-cap is the knob. Four columns is a hard maximum (asked for on
     2026-08-30), so the side margin on a big panel is entirely a consequence
     of how wide a card is allowed to get — there is no third option. Measured,
     at a 3840px panel: 22rem leaves 23.1% of the screen as margin each side,
     24rem leaves 20.8%, 26rem leaves 18.5%. 24rem is the shipped compromise —
     past that the card is mostly air, because its content is short lines and a
     name plate rather than a photograph.

     rem, NOT px, and that is why there is ONE set of these rather than four:
     the bands below lift the root font (17/19/22px), so the cap ramps with the
     type on its own and the card stays the same size RELATIVE TO ITS OWN TEXT
     at every width. Same rule the 1900px block states for every other
     component. */
  --card-cap: 24rem;
  --card-gap: 1.75rem;
  --catalogue-col: calc(4 * var(--card-cap) + 3 * var(--card-gap));

  /* The gutter between the page column and the screen edge. Lifted per band
     below; it is the ONE length here chosen by eye rather than derived,
     because it spaces the page against the SCREEN rather than text against
     its own box. .container reads it for its padding so the column maths and
     the padding can never drift apart. */
  --page-pad: 24px;

  /* What .container is actually capped at. Replaced wholesale above 1400px. */
  --page-col: 1160px;

  /* #/admin is the one surface that does NOT ride the catalogue's column: its
     tables want the whole band. These are the container caps the page itself
     used before the column was derived, kept verbatim so the dashboards did
     not move when it was. */
  --admin-col: 1160px;

  /* -- The console's own ground ----------------------------------------------
     Asked for on 2026-09-21: "style the Administration UI, include appealing
     background color". The console used to sit on --bg, the same cream as the
     catalogue, so the one screen that writes to the live database looked exactly
     like the one that browses it. These three give it a ground of its own.

     A PALE SAGE, NOT A NEW HUE. #/admin opens between two saturated
     --surface-band bars — the fixed navbar above and the footer below, which
     renders on this route too — so a ground from any other family would put
     three unrelated colours down one screen. This is the same green, taken most
     of the way to white.

     MEASURED, because everything that sits DIRECTLY on this ground is text the
     panels do not own — the masthead heading and its line about writing to the
     live database. By the WCAG relative-luminance formula:

       --text     #1d2b20 on --admin-ground ... 12.24:1  clears AAA
       --text-dim #5a6b5c on --admin-ground ...  4.70:1  clears AA, not AAA

     The dim figure is what fixed the value. A ground one step deeper (#dbe5d6)
     measures 4.38:1 and fails, so this is close to as dark as the ground can go
     while the masthead's second line stays in the ink the rest of the file uses
     for a second line. The alternative — promoting that line to full --text —
     buys a darker ground at the cost of making a note read like a heading.

     THE CARDS LIFT MORE THAN THEY DID, which is the point of the change.
     --card #fffdf7 against this ground is 1.19:1 where it was 1.07:1 against
     --bg. Neither is a boundary WCAG would accept unaided, and on this ground
     the --border hairline does not rescue it either — measured, --border
     #d7dfd2 against --admin-ground is 1.129:1, a colour chosen for cream and
     all but gone here. What separates a panel is --shadow plus that 1.19:1
     step; the hairline is kept because it still does the work on every cream
     route the same class appears on. The gain is that a row of white panels now
     reads as panels rather than as regions of one sheet.

     --admin-thead is the table's own heading strip, about two thirds of the way
     from --card to the ground (0.63 per channel, 0.65 by luminance) so the head
     separates from the body inside a panel without becoming a second band:
     --text-dim on it is 5.01:1. Named --admin-thead and NOT --admin-head-row,
     which is a live layout class fifty lines into the admin block — a token and
     a selector sharing a name with only a leading -- between them is a wrong
     edit waiting to happen in a file navigated by grep.

     --admin-row-bad is the bulk upload's "this row would not parse" tint. It is
     a token rather than a literal only because the stripe below forced the rule
     to be restated at a higher specificity, and one colour written twice is one
     colour that drifts.

     --admin-stripe is the alternating table row, and it is deliberately FAINTER
     than the --bg-soft that :hover paints. Zebra and hover are two signals on
     the same surface, and if the resting stripe is as strong as the pointer's
     the pointer stops saying anything. Composited over --card the stripe is
     L 0.942 and --bg-soft is L 0.853, so hover still visibly darkens an even
     row. Note the alpha is quantised to 11/255 on the way to the screen, so the
     composite is measured from that rather than from the 0.045 written here. */
  --admin-ground: #e4ecdf;
  --admin-thead: #eef2e8;
  --admin-stripe: rgba(111, 159, 111, 0.045);
  --admin-row-bad: rgba(192, 57, 43, 0.06);
}

/* ==== END: Design tokens =================================================== */


/* ==== BEGIN: Base / reset ================================================== */

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: "Segoe UI", system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif;
  scroll-behavior: smooth;
}

#root { min-height: 100vh; }

a { color: inherit; text-decoration: none; }

button { font-family: inherit; cursor: pointer; }

h1, h2, h3 { letter-spacing: -0.2px; }

/* ==== END: Base / reset ==================================================== */


/* ==== BEGIN: Layout — page bands & container =============================== */
/* Each band paints edge to edge; the .container inside it keeps content on one
   shared grid. See the App component in app.jsx. */

.page {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  background: var(--bg);
}

/* Navbar band — pinned to the top of the viewport, so it sits above the page
   content and out of normal flow. A soft shadow separates it from the cream
   below; a hairline border would read as a highlight against the sage. */
.navbar-wrap {
  position: fixed;
  top: 0;
  left: 0;
  /* --scrollbar-gutter is set by useScrollLock while an overlay is open. Taking
     the body out of flow removes the document scrollbar, and this bar is fixed
     with right: 0, so without the fallback-guarded variable it would spread
     about 15px into the space the scrollbar just vacated -- the whole navbar
     visibly jumping wider the moment a modal opens. 0px when nothing is
     locked, which is every other moment. */
  right: var(--scrollbar-gutter, 0px);
  z-index: 100;
  background: var(--surface-nav);
  box-shadow:
    0 1px 0 rgba(28,46,32,0.14),
    0 4px 16px rgba(28,46,32,0.08);
}

/* Body band — creamy white, with two very faint green glows behind the hero
   that echo the sage bands. */
.body-wrap {
  flex: 1;
  padding-top: var(--nav-h); /* clears the fixed navbar above */
  background:
    radial-gradient(1000px 500px at 10% -10%, rgba(16,185,129,0.10), transparent),
    radial-gradient(800px 400px at 100% 0%, rgba(0,212,181,0.07), transparent),
    var(--bg);
}

/* THE SAME BAND, WEARING THE CONSOLE'S GROUND. Set on the band and not on
   .admin-wrap, because a background belongs to the thing that paints edge to
   edge and .admin-wrap is a .container — it stops at the column. On #/admin,
   <AdminView /> is the only child of this element (app.jsx returns it before the
   normal tree), so this colours the console and can reach nothing else.

   The full-bleed alternative, a 100vw pseudo-element under .admin-wrap, is
   wrong here for one reason and it is not the obvious one. 100vw is measured
   against the initial containing block with scrollbars assumed ABSENT, so on a
   page with a document scrollbar it is wider than the client area and puts a
   horizontal scroll on a file whose comments fight horizontal scroll in half a
   dozen places (.admin-table-scroll at the Tables banner below, .admin-message-
   body, the product table, .bulk-body). What it does NOT do is move when
   useScrollLock takes that scrollbar away — same reason — and the responsive
   section says so at the "100vw does not change" note, which is why 88vw is
   safe for --page-col there. Stated because the plausible-sounding second
   objection is the false one.

   One glow rather than the catalogue's two, pinned to the top where the tab
   strip is, and in the sage rather than the hero's emerald and teal — those two
   exist to sit behind the hero banner, which this route does not render. Without
   it a 1160px column of flat colour under a saturated navbar looks like a
   rendering fault rather than a choice. */
.body-wrap.is-admin {
  background:
    radial-gradient(1100px 420px at 50% -8%, rgba(111,159,111,0.22), transparent),
    var(--admin-ground);
}

.container {
  max-width: var(--page-col);
  margin: 0 auto;
  padding: 0 var(--page-pad);
  width: 100%;
}

/* The admin dashboards keep the full band — see --admin-col. 0,2,0 against the
   0,1,0 above, so this wins wherever either rule sits in the file.

   max(), and it is not decoration. --admin-col is the container ladder the page
   itself used before the column was derived from the catalogue, and that ladder
   still steps at 1600/1900/2400/3200 while the page column now starts widening
   at 1400. Taken on its own it would make the dashboards NARROWER than the
   storefront over 1400–1899 — measured, 1160 against 1394.8 at a 1600px
   monitor, an admin screen visibly inset from the site it administers. The
   whole point of the exclusion is that these tables want MORE width than the
   catalogue's measure, never less. */
.container.admin-wrap { max-width: max(var(--admin-col), var(--page-col)); }

/* Anchor targets would otherwise land underneath the fixed navbar when jumped
   to from the hero buttons, or from an #about deep link. */
.products,
.company-strip,
.profile {
  scroll-margin-top: calc(var(--nav-h) + 20px);
}

/* ==== END: Layout — page bands & container ================================= */


/* ==== BEGIN: Navbar ======================================================== */
/* Three zones: brand, search (grows), then the sign-in and cart actions. */

/* WRAPS AT EVERY WIDTH as of 2026-08-30, not only below 767px.

   The 767px block below already wrapped this row and gave the search its own
   full-width line, and that width was chosen for a PORTRAIT phone. A phone held
   sideways is 915px and misses it entirely — and it is the harder case, because
   landscape is where the breadcrumb, the search and three actions all want the
   same row. Wrapping unconditionally means the row breaks when it does not fit
   rather than when a number in this file says it might not, so a long company
   name in the crumb is handled at 1023px the same way it is at 700px.

   It costs nothing where everything fits: a flex line that is not overfull does
   not wrap. And the bar's height is measured rather than declared —
   useFixedNavHeight writes --nav-h on every resize — so a row that becomes two
   moves the page down with it instead of hiding under the bar. */
.navbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 1.125rem;
  padding: 0.875rem 0;
}

/* The plate-to-name gap was 0.6875rem — 11px against a 38px plate, 0.29 of the
   mark. Reported on 2026-08-30 as no spacing at all, and from three metres on a
   TV that is a fair description: the monogram plate and the "S" of "Savannah"
   read as one object. 0.9375rem is 15px at a 16px root and 0.39 of the plate,
   which is the ratio the lockup was drawn at. In rem, so it holds that ratio
   through the 17/19/22px bands (15.9/17.8/20.6px) instead of tightening as the
   plate grows. The 479px block below keeps its own smaller figure — the plate
   is smaller there too. */
.brand {
  display: flex;
  align-items: center;
  gap: 0.9375rem;
  flex-shrink: 0;
}

/* The wordmark, STACKED: "Savannah" over "PHARMACEUTICALS LIMITED" in small
   tracked capitals. Two lines rather than one because the full legal name is
   what the bar should say and one line cannot afford it -- inline, and showing only
   the TRADING name, it measured 286px and left a company-page search field 0px at
   768px, which is why the old bar printed a truncated "Savannah" from 1023px down.
   Stacked, the whole LEGAL name measures 205px: the bar gained "Limited" and lost
   80px at the same time, which is the whole argument for the second line.

   Still ONE flex item, and that is load-bearing: .brand is a flex row with a
   0.9375rem gap, so two bare children would open that gap inside the name itself. */
.brand-name {
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* nowrap on the column, not the lines: .brand does not shrink, so there is
     nowhere for a THIRD line to go if either of these two wrapped. */
  white-space: nowrap;
}

.brand-line-a {
  font-weight: 800;
  font-size: 1.22rem;
  line-height: 1.04;
  letter-spacing: -0.2px;
  color: var(--brand-ink);
}

/* Hierarchy here is size, weight and tracking ONLY -- deliberately not opacity.
   Dropped to 82% over the sage band this line measures 4.1:1, and at ~9px it is
   the smallest type on the bar, which is the last place to spend contrast. Full
   --brand-ink keeps it at 5.4:1. */
.brand-line-b {
  margin-top: 2px;
  /* Cancels the trailing letter-space the tracking adds after the final "D",
     which would otherwise pad the column 1.4px wider than its glyphs and leave
     the lockup looking left-shifted under "Savannah". */
  margin-right: -0.145em;
  font-weight: 700;
  font-size: 0.575rem;
  line-height: 1.1;
  letter-spacing: 0.145em;
  text-transform: uppercase;
  color: var(--brand-ink);
}

/* The "SP" monogram on its sunset plate. See --sunset-* / --brand-ink for why
   the ink is dark rather than the cream the old purple plate carried. */
.brand-mark {
  width: 2.375rem;
  height: 2.375rem;
  border-radius: 0.6875rem;
  background: linear-gradient(145deg, var(--sunset-1) 0%, var(--sunset-2) 52%, var(--sunset-3) 100%);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 800;
  font-size: 0.98rem;
  letter-spacing: 0.5px;
  /* Centres the pair against that tracking: flex centres the whole advance
     width, trailing letter-space included, which sits "SP" a hair to the left. */
  text-indent: 0.5px;
  color: var(--brand-ink);
  flex-shrink: 0;
  transition: box-shadow 0.15s ease, transform 0.15s ease;
  /* The monogram is safe (7.4:1) but the PLATE is not: sampled against the sage
     at its four corners it runs 1.2:1 to 1.9:1, so the square itself has almost
     no edge to stand on and dissolves into the band at a glance. Hence a border
     drawn in shadow rather than none: pale inner rim to lift the top, hairline to
     draw the boundary, soft drop to seat it. This is the shape's contrast, not
     the text's, which is why it is solved with an outline instead of a colour. */
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.55),
    0 0 0 1px rgba(28,46,32,0.18),
    0 2px 6px rgba(28,46,32,0.22);
}

/* 38px of plate is a big enough target to want feedback, which the old 32px
   square never had. Hovering ANYWHERE on the lockup lifts it -- the trigger is
   the whole link, the thing that moves is only the badge, so the name does not
   shift under the pointer.

   `a.brand`, not `.brand`: the footer heading wears the same class and is an
   <h2>. Unqualified, this would make a non-interactive heading animate under the
   pointer and promise a click that is not there. */
/* Hover states live behind @media (hover: hover) from here on, for the
   reason set out at length over .product-card below: a touch browser with
   no pointer fires :hover on tap and then LEAVES IT APPLIED until something
   else is tapped, so on a phone the last thing touched keeps its hover look
   while the customer scrolls. That comment already guarded the card's dark
   inversion; these thirty-five rules are the ones it did not reach, found by
   listing every :hover in the file and subtracting the ones already inside a
   guard. The wrapped rules keep their own indentation so the diff shows the
   wrapper and nothing else, and a media query adds no specificity, so every
   one of them still wins and loses against exactly what it did before. */
@media (hover: hover) {
a.brand:hover .brand-mark {
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.65),
    0 0 0 1px rgba(28,46,32,0.24),
    0 4px 10px rgba(28,46,32,0.28);
  transform: translateY(-1px);
}
}

/* -- Search ---------------------------------------------------------------- */

/* THE FLOOR IS THE POINT OF THIS RULE, and it replaced `min-width: 0`.

   `min-width: 0` let the field shrink to whatever was left, and what is left is
   not always more than the field's OWN Search button: that button is absolutely
   positioned against this box, so once the box is narrower than the button plus
   its inset, the button covers the input entirely. Measured on 2026-08-30 at
   915x412 — a phone held sideways on a company page, where the breadcrumb is
   also on the row — the box was 145px and the button 70px of it, leaving 71px
   of field showing about six characters, with the button drawn over the
   placeholder. That is the "Search button overlaps text" report, and it is an
   overlap rather than a squeeze precisely because the button is out of flow.

   14rem is the button (4.375rem), the leading magnifier and eight characters of
   a real query. Below that the field is not worth keeping on the row, which is
   what the wrap on .navbar is for: the search drops to a line of its own rather
   than being crushed. A floor with nowhere to go would only trade the overlap
   for a horizontal scrollbar, so the two changes are one change. */
.nav-search {
  position: relative;
  display: flex;
  align-items: center;
  flex: 1;
  min-width: 14rem;
  max-width: 420px;
}

/* Sits over the field, not the sage band, so it takes the input's ink at 55%.
   Larger than the 0.8rem the 🔍 it replaced needed — see .showcase-tile-icon
   for the same correction. */
.nav-search-icon {
  position: absolute;
  left: 12px;
  font-size: 0.95rem;
  color: var(--text);
  opacity: 0.55;
  pointer-events: none;
}

/* The right padding is the Search button's width plus its inset, so a long
   query scrolls under the button rather than behind it. */
.nav-search input {
  width: 100%;
  padding: 0.625rem 5.25rem 0.625rem 2.25rem;
  border-radius: 999px;
  border: 1px solid rgba(28,46,32,0.18);
  background: rgba(255,255,255,0.94);
  color: var(--text);
  font-family: inherit;
  font-size: 0.9rem;
  outline: none;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.nav-search input::placeholder { color: #7b8a7d; }

.nav-search input:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(108,92,231,0.18);
}

/* Absolutely positioned inside the pill, like the magnifier opposite it, so
   adding it changed nothing about how the field sizes itself. It carries .btn
   and .btn-primary from <Button /> and overrides only what has to change to fit
   a 40px field. Three of those overrides are load-bearing:

   1. The selector is a DESCENDANT PAIR, not the bare class. .btn is declared
      further down this file, so at equal specificity its own padding and radius
      win on source order and the button renders full height, breaking out of
      the field. Same trap as .footer-to-top's min-width — see the note there.

   2. FLAT --accent, not .btn-primary's gradient. This is the smallest primary
      button on the site (0.78rem against 0.92rem everywhere else), and white on
      the gradient measured 3.69:1 at worst under this label, 4.39:1 at best —
      both under the 4.5:1 AA asks of text this size. Rasterised from the
      composited stack, not read off the two stops. Flat is 4.86:1. Same finding
      and same fix as the hero banner's buttons.

   3. The hover inverts .btn-primary's brightness(1.08), which would lift
      #6c5ce7 straight back under AA. Darkening can only raise the ratio: 5.74:1.

   --accent here is always the site violet, on all eleven pages: companyStyle()
   puts a company's accent on .company-page and the navbar is a sibling band
   outside it. That is why the contrast above is one figure rather than eleven,
   and why .nav-search input:focus can hardcode a violet ring. The found-card
   ring is the opposite case — see it below. */
.nav-search .nav-search-go {
  position: absolute;
  right: 4px;
  padding: 0.4375rem 1rem;
  border-radius: 999px;
  font-size: 0.78rem;
  background: var(--accent);
  z-index: 1;
}

@media (hover: hover) {
.nav-search .nav-search-go:hover { filter: brightness(0.9); }
}

/* Company name beside the brand on a landing page. Full --text, not
   --text-dim: the dim ink only reaches 1.9:1 on the sage band. The separator
   is dimmed with opacity instead, which keeps the same hue. */
.nav-crumb {
  flex-shrink: 0;
  font-size: 0.9rem;
  font-weight: 600;
  white-space: nowrap;
}

/* THE SEPARATOR IS DIMMED BY ITS OWN CLASS, not by a blanket `.nav-crumb span`.
   The blanket form worked only while the crumb held nothing but a chevron and a
   text node: an element placed beside them was dimmed to 0.55 as well, and
   undoing that took a two-class selector — (0,2,0) against (0,1,1) — because a
   single class loses to a class-plus-type wherever in the file it sits. Scoped
   here, anything added inside the crumb opts in rather than having to opt out.
   Labels are bare text nodes, at full contrast with no rule of their own. */
.nav-crumb-sep {
  opacity: 0.55;
  margin-right: 4px;
}

/* -- Actions & cart badge -------------------------------------------------- */

.nav-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-left: auto;
  flex-shrink: 0;
}

/* WHO IS SIGNED IN, AS TWO LETTERS ON A ROUND PLATE.

   This used to print the whole name, truncated at 150px, and it is the reason
   the button was reported on 2026-08-30 as an empty white pill on a phone: the
   name was the button's only content and the 479px block hid it, so what was
   left was a control containing nothing. The name was also the most expensive
   thing on the bar — .nav-actions measured 312px at 915x412 and left the search
   field 145px, of which its own Search button took 70.

   FIXED, NOT max-width, and square: a circle drawn by border-radius is only a
   circle while the box is. Two letters at 0.82rem sit inside 2.75rem with room
   to spare, and 2.75rem is 44px at a 16px root, which is the touch minimum the
   any-pointer block states for every other control on this bar.

   IT KEPT .btn's OWN SURFACE UNTIL 2026-09-14, and the reasoning is left here
   because half of it still holds and the other half was overruled on
   instruction rather than found to be wrong.

   What was argued: the plate takes the same cream surface and ink as Admin
   beside it and does NOT take .brand-mark's sunset gradient, because two sunset
   plates at opposite ends of one bar would read as a matched pair and they are
   not a pair — one is the company and one is whoever is signed in. And nothing
   was keyed to the account, because a colour per customer would be a second
   identity for the same person on a bar that already has one.

   WHAT CHANGED: the instruction of 2026-09-14 keys this plate to the account's
   gender — pink for female, sunset yellow for male. So the second argument is
   simply overruled; that is the user's call and it is recorded, not smuggled.

   THE FIRST ARGUMENT IS ANSWERED, NOT OVERRULED, and it is why the male plate
   is FLAT --sunset-1 and not .brand-mark's gradient. A gradient at both ends of
   the bar is what would have read as a matched pair; one flat disc and one
   gradient plate do not, and the --brand-ink ring the coloured plates carry
   (see .btn.nav-account-btn-*) separates them further. A cream plate is still
   what an account with no gender gets, which is every account made before that
   date, so the surface described above is the default rather than the rule. */
.nav-account-initials {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  font-size: 0.82rem;
  font-weight: 800;
  letter-spacing: 0.03em;
  /* Centres the pair against that tracking — see .brand-mark, same reason. */
  text-indent: 0.03em;
  line-height: 1;
}

.cart-btn { position: relative; }

/* The sage-coloured ring lifts the badge off both the purple button and the
   band behind it. */
.cart-badge {
  position: absolute;
  top: -7px;
  right: -7px;
  min-width: 1.3125rem;
  height: 1.3125rem;
  padding: 0 0.3125rem;
  border-radius: 999px;
  border: 2px solid var(--surface-band);
  background: var(--danger);
  color: #fff;
  font-size: 0.7rem;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ==== END: Navbar ========================================================== */


/* ==== BEGIN: Buttons ======================================================= */
/* Shared by the navbar, hero, product cards, cart and auth forms. inline-flex
   so the class also works on <a> elements (the hero uses anchor buttons). */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.625rem 1.125rem;
  border-radius: 0.625rem;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-size: 0.92rem;
  font-weight: 600;
  text-align: center;
  transition: all 0.15s ease;
}

@media (hover: hover) {
.btn:hover { border-color: var(--accent); }
}

.btn-primary {
  background: linear-gradient(135deg, var(--accent), var(--accent-lift));
  border: none;
  color: white;
}

@media (hover: hover) {
.btn-primary:hover { filter: brightness(1.08); }
}

/* The one button on the site that destroys something. Added 2026-08-31 with the
   order delete, and there is exactly one caller.

   SOLID, NOT OUTLINED. An outlined danger button reads as a secondary action,
   and the whole job of this one is to be unmistakably the button that does the
   irreversible thing. It sits beside a plain "Keep it" in the confirmation, so
   the pair is quiet-and-loud rather than two greys somebody has to read twice.

   White on --danger is measured, not assumed: #c0392b against #ffffff computes
   to 5.44:1, which clears AA at any size (4.5:1) with room, and does NOT reach
   AAA (7:1) — stated so nobody re-derives it later and is surprised.

   The hover DARKENS rather than brightening the way .btn-primary does, and that
   is the reason rather than the taste: brightness(1.08) on a mid-red walks it
   toward pink and takes the white text down WITH it, so the loudest button on
   the site would be at its least legible exactly while under the pointer. 0.92
   moves it the other way and the ratio only improves. */
.btn-danger {
  background: var(--danger);
  border: 1px solid var(--danger);
  color: #fff;
}

@media (hover: hover) {
  .btn-danger:hover { filter: brightness(0.92); }
}

.btn-danger:focus-visible {
  outline: 2px solid var(--danger);
  outline-offset: 2px;
}

/* The navbar plate's fill, promoted to a button variant. TWO callers, and they
   sit on two DIFFERENT backgrounds: "Back to top" on the sage footer band, and
   "All companies" on the cream body of a company page. One class serves both,
   which is only possible because the ring is drawn in --brand-ink: that clears
   1.4.11's 3:1 against each of them (5.4:1 on the sage, 15.2:1 on the cream). The
   white ring the blue Back to top used to carry could not have moved here — on
   cream it measures 1.09:1 and is simply not there.

   THE LABEL IS DARK, and that is not a taste call. White on this fill measures
   2.0:1. --brand-ink, sampled the way the --sunset-* tokens describe but masked
   to each label's own rect, measures 6.6:1 at worst and 9.7:1 at best. The worst
   case is the 154px "All companies", not the 190px "Back to top" (7.0:1): a
   shorter box runs the same three stops over less distance, so proportionally
   more of the dark orange end sits under the label. BOX SIZE MOVES THESE NUMBERS
   and the type does not scale with it, so re-measure rather than assume if either
   button's width changes. (The badge is the opposite case — see .brand-mark.)

   THE RING IS NOT TRIM, for the same reason it was not on the blue: the fill is
   1.2-1.9:1 against the sage and 1.5:1 against the cream, so this button cannot
   be separated from either band by colour alone. Take the ring away and neither
   placement passes. A softer brown ring was the nicer-looking option and is the
   one to avoid: #7a3f0f is 2.7:1 on the sage, under the line. */
/* THE CONFIRMATION'S "Keep it" JOINS THIS SELECTOR INSTEAD OF TAKING THE CLASS,
   and that is a build constraint rather than a preference. <Button> already
   accepts variant="sunset", which is how every other sunset button gets here.
   But the browser runs app.jsx COMPILED into index.html and tools/mirror.ps1 is
   the only thing that compiles it — and it fails on this machine's Edge
   ("Multiple targets are not supported in headless mode", 2026-09-03), so a JSX
   edit cannot be shipped at all. Extending the selector reuses these exact
   declarations, which is the point: the two buttons cannot drift apart, where
   copying the gradient into a second rule guarantees they eventually would.
   Move this to variant="sunset" and delete the extra selector from all three
   rules once mirror.ps1 runs again.

   :not(.btn-danger) rather than :first-child, so it names the NON-DESTRUCTIVE
   button rather than a position. The stacked order can then be reversed without
   walking this fill onto the button that deletes.

   RE-MEASURED FOR THE NEW BOX, because the note above requires it whenever a
   button's width changes: this one is 354px full-width, not the 154px the 6.6:1
   worst case was taken on. --brand-ink against the three stops is 10.33:1,
   8.16:1 and 6.24:1, so the floor is 6.24:1 at the darkest end — AA for normal
   text with room to spare, below AAA. The label is centred on a 354px box, so
   it actually sits nearer the 52% stop's 8.16:1; 6.24:1 is the bound, not the
   likely case. Method checked against this file's own figure: white on the 52%
   stop computes 2.03:1 where the note above states 2.0:1. */
.btn-sunset,
.admin-confirm-actions .btn:not(.btn-danger) {
  border: 2px solid var(--brand-ink);
  background: linear-gradient(145deg, var(--sunset-1) 0%, var(--sunset-2) 52%, var(--sunset-3) 100%);
  color: var(--brand-ink);
  font-weight: 700;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.55),
    0 8px 20px rgba(28,46,32,0.26);
}

/* Brightens AND lifts. The brighter gradient is safe by construction — every
   stop moves toward white, so dark-ink contrast only goes UP: the two worst
   pixels go 6.6:1 → 7.4:1 and 7.0:1 → 7.8:1. That is the exact opposite of the
   trap the blue had, where a brighter fill walked its white label under AA, and
   it is why this variant may brighten on hover where the blue could not. */
@media (hover: hover) {
.btn-sunset:hover,
.admin-confirm-actions .btn:not(.btn-danger):hover {
  /* Repeats the ring, because `.btn:hover` would otherwise recolour it to
     --accent and the ring is the whole of this button's edge. */
  border-color: var(--brand-ink);
  background: linear-gradient(145deg, #ffcc63 0%, #ffae44 52%, #f78c33 100%);
  transform: translateY(-2px);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.65),
    0 14px 30px rgba(28,46,32,0.32);
}
}

/* Ink rather than white, so the one rule works on the sage band and the cream body
   both — 5.4:1 and 15.2:1 against those. The 3px offset puts it on the background
   rather than on the plate, so those two are the figures that matter; against the
   plate itself it would still clear, at 6.6:1 worst. */
.btn-sunset:focus-visible,
.admin-confirm-actions .btn:not(.btn-danger):focus-visible {
  outline: 3px solid var(--brand-ink);
  outline-offset: 3px;
}

/* btn-gold and btn-sm lived here for the product card's "Add" button, and the
   gold/gold-light/gold-ink tokens above existed only to colour it. That button
   is now a quantity stepper (qty-stepper), so all five had no remaining user
   and were removed rather than left as dead rules. The star token is separate
   and still colours the rating glyph.

   Class names in this comment are written WITHOUT a leading dot on purpose:
   the class-name audit greps for `.name` and reads a dotted name in a comment
   as a live selector, so it reported all five as dead CSS on every run. */

/* "Go back" — the checkout's two navigation buttons, one on the cart and one on
   the confirmation. The sage the navbar and footer bands already carry, so the
   step BACKWARDS reads as furniture rather than as a third call to action, while
   still being a real filled button: the checkout asked for backgrounds on its
   buttons, and sage is the one brand surface not already spoken for — the accent
   is .btn-primary's and the sunset is "Pay on delivery"'s.

   THE LABEL IS DARK INK, MEASURED. --text #1d2b20 on --surface-band #6f9f6f
   computes 4.84:1 by the WCAG relative-luminance formula, which clears AA for
   normal text (4.5:1) and does NOT reach AAA (7:1) — stated so nobody
   re-derives it later and is surprised. It agrees with the 4.8:1 the --text
   token's own note already quotes for this band, which is the cross-check.
   White is not available here: it measures 2.9:1 on this fill.

   UNLIKE .btn-danger THIS HOVER BRIGHTENS, and the direction is forced rather
   than chosen. The label is DARK, so lifting the fill toward white moves the two
   apart and the ratio only improves; darkening it — which is right for the white
   label on --danger — would walk the fill toward its own ink and take 4.84:1
   DOWN, at the one moment the button is under the pointer. Same argument
   .btn-sunset makes, same conclusion. 1.06 is enough to read as a state change
   and keeps the fill recognisably the band's colour.

   NO `transform` ON HOVER, unlike .btn-sunset. These two sit directly under a
   button that commits something, and a control that jumps toward the pointer is
   the wrong feel a few pixels above "Pay on delivery". */
.btn-sage {
  background: var(--surface-band);
  border: 1px solid var(--surface-band);
  color: var(--text);
}

@media (hover: hover) {
  .btn-sage:hover {
    /* Repeats the edge, because `.btn:hover` would otherwise recolour it to
       --accent and leave a purple ring on a green button. */
    border-color: var(--brand-ink);
    filter: brightness(1.06);
  }
}

.btn-sage:focus-visible {
  outline: 2px solid var(--brand-ink);
  outline-offset: 2px;
}

.btn-block { width: 100%; }

/* aria-disabled JOINS :disabled HERE, and the pair is the mechanism rather than
   belt-and-braces. "Pay on delivery" on the confirmation has to LOOK unavailable
   while staying pressable, because the refusal that names the KSh 99,999 floor
   is reachable only by pressing it — a real `disabled` attribute swallows the
   click, and the customer is left with a dead control and no explanation. So the
   attribute carries the state to assistive tech and this selector carries the
   same dimming the genuine article gets, out of ONE set of declarations rather
   than a copy that would eventually drift from it.

   THE GATED "Pay on delivery" NO LONGER TAKES THIS OPACITY, and .btn-quiet
   below is where it goes instead and why. Everything else that is disabled or
   aria-disabled still lands here unchanged. */
.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.5;
  cursor: not-allowed;
}

@media (hover: hover) {
.btn:disabled:hover,
.btn[aria-disabled="true"]:hover {
  filter: none;
  border-color: var(--border);
}
}

/* "Pay on delivery" WHILE IT IS BELOW THE FLOOR — the one caller, from
   <PlacedOrder />'s variant={deliveryAllowed ? "sunset" : "quiet"}.
   Added 2026-09-04 on the instruction that this button carry a background
   colour; it had none anybody could see, and the arithmetic below is why it is
   an opaque muted fill rather than the obvious two alternatives.

   IT ASKED FOR A BACKGROUND AND HAD ONE ALL ALONG, which is the actual fault.
   Plain .btn is `background: var(--card)` — #fffdf7, the very colour
   .modal-card paints behind it — with a --border hairline that measures
   1.34:1 against that same surface. A fill identical to its backdrop and an
   edge nobody can see is a button that reads as a gap in the panel. It was
   never missing a background; it was wearing the panel's.

   WHY NOT JUST .btn-sunset FOR BOTH STATES, the one-word change that was tried
   first: the shared `opacity: 0.5` above composites the WHOLE button, label and
   fill together, toward the backdrop. --brand-ink on the three sunset stops is
   10.33:1 / 8.16:1 / 6.24:1 at full opacity, and 2.58:1 / 2.30:1 / 2.02:1 once
   washed at 50% over #fffdf7 — WORSE than the 3.05:1 the near-white plain
   button it would replace manages, because dimming a near-white fill costs it
   nothing while dimming a saturated one costs it everything. The sunset
   button's whole advantage is a mid-tone fill under dark ink, and that is
   exactly the structure a 50% wash toward near-white dissolves. 1.4.3 exempts
   inactive components, but this control is pressable BY DESIGN — the note above
   says why — so leaning on that exemption for it is thinner than it looks.

   WHY NOT --bg-soft, the other obvious pick: #edefe4 is 1.14:1 against
   #fffdf7. It is a real colour and an invisible one here, which is the
   complaint this change exists to answer.

   SO: SUNSET AT 35% OVER THE CARD, OPAQUE IN EFFECT, AND NO WASH. The
   translucent-tint construction is .pay-arranged's (3px rule, 8% wash) taken up
   a stop, so the quiet button is recognisably the same family as the live one.
   rgba(242,128,42,0.35) over #fffdf7 resolves to #fad1af, and --brand-ink on
   that measures 11.73:1 — better than the LIVE sunset button's own 6.24:1
   floor, and nearly four times the 3.05:1 it replaces. The gradient, the 2px
   ring, the 700 weight, the inset highlight and the drop shadow all stay with
   .btn-sunset, so "available" and "gated" remain two obviously different
   buttons; this one is a flat pastel with a hairline.

   THE FILL READS BY HUE, NOT BY LUMINANCE, and that is not a defect of this
   choice — it is true of the live button too. #fad1af is 1.39:1 against the
   panel where --sunset-1 is 1.58:1 and --sunset-3 2.61:1; none of these
   separates from a cream panel on lightness. What carries the BOUNDARY is the
   ring: --brand-ink at 16.31:1 against #fffdf7, where the hairline it replaces
   managed 1.34:1. That is the 1.4.11 argument, and it is the ring's to make. */
.btn-quiet {
  background: rgba(242, 128, 42, 0.35);
  border: 1px solid var(--brand-ink);
  color: var(--brand-ink);
}

/* OVERRIDES the shared dim rather than joining it — same specificity, later in
   the file. The muting is in the FILL now, so washing it as well would undo the
   whole measurement above. `cursor: not-allowed` is deliberately NOT overridden:
   it still comes from the rule above, so the pointer keeps saying what the
   colour no longer has to. */
.btn-quiet[aria-disabled="true"] { opacity: 1; }

/* Repeats the ring for the same reason .btn-sage and .btn-sunset do: the reset
   above would recolour it to --border on hover, which is a visible flicker from
   16.31:1 to 1.34:1 on a control that is not going anywhere. No brighten and no
   lift — this state is not offering anything. */
@media (hover: hover) {
  .btn-quiet[aria-disabled="true"]:hover { border-color: var(--brand-ink); }
}

.btn-quiet:focus-visible {
  outline: 2px solid var(--brand-ink);
  outline-offset: 2px;
}

/* THE DESTRUCTIVE CHOICE, and it is a MODIFIER rather than a fifth variant.
   `is-danger` rides on top of whatever .btn-* a call site already chose, so the
   one place that needs a red button — "Quit Sign In" on the registration fee
   screen — does not fork the variant list for a single use.

   IT OVERRIDES .btn-quiet's fill, which is why it is later in the file and
   carries the .btn prefix: without the extra specificity the quiet orange wash
   would win and the button would read as the gentler of the two choices on a
   screen whose other button takes a payment. Those must not look equivalent.

   --danger is the token the admin destructive controls already use; a second
   red defined here would be a palette that drifts. */
.btn.is-danger {
  background: var(--danger);
  border: 1px solid var(--danger);
  color: #ffffff;
}

.btn.is-danger:hover { filter: brightness(1.08); }

.btn.is-danger:focus-visible {
  outline: 2px solid var(--danger);
  outline-offset: 2px;
}

/* Inline text button — "Forgot password?" and the login/signup switch. */
.link-btn {
  background: none;
  border: none;
  color: var(--accent-2);
  font-size: 0.85rem;
  font-weight: 600;
  padding: 0;
}

/* ==== END: Buttons ========================================================= */


/* ==== BEGIN: Hero banner =================================================== */
/* The panel the hero copy sits on: a background clip, patterned with the ten
   dosage forms. Five layers, z-index 0-4, inside `isolation: isolate` so the
   stack is local to the panel and cannot interact with the fixed navbar's
   z-index: 100.

     0  .hero-video   the background clip, object-fit: cover
     1  ::before      drifting colour wash (the animated part)
     2  ::after       flat veil over the whole panel - the contrast guard
     3  .hero-motifs  ten dosage-form icons, spread over the whole panel and
                      spinning (no company monogram or wordmark - see <Hero />)
     4  .hero         the copy, over its own plate at z-index -1

   THE PANEL IS DARK, and that is the clip's doing rather than a taste call.
   Sampled over the real file - 24 frames x 2 banner crops, 131k pixels inside
   the band the copy occupies - it runs dark: median relative luminance 0.07,
   average RGB (95,78,66). Dark ink on that is unreadable, and a veil light
   enough to keep the old cream panel would need about 0.9 alpha, at which point
   there is no visible video left to have added. So the inks flipped to pale and
   the panel went dark with them.

   Nothing here depends on the clip loading. The panel's own background is the
   same dark, so a failed fetch, an unsupported codec, or prefers-reduced-motion
   (where <Hero /> does not render the video at all) degrades to a plain dark
   panel - never to pale text on cream.

   THE GUARD, layer 2. The clip's median is dark but its HIGHLIGHTS reach 0.767,
   and for pale copy it is the highlights that decide legibility. A flat 0.55
   veil of #101a13 pins the brightest pixel in the copy band at 0.171, which is
   what puts every ink below through AA. Flat and static on purpose: a guarantee
   that animates, or that fades out before the panel edge, is not one - the
   motifs are out there too.

   THE PLATE, inside layer 4. One veil cannot both protect the text and leave the
   motifs visible, because the motifs cross the text: a tint bright enough to
   read against the clip collapses a pale line that drifts over it. Measured at a
   single veil the two demands meet exactly at luminance 0.16 - a knife edge with
   no value satisfying both. The plate splits them by sitting above the motif
   field and below the words. Without it the motif ceiling is opacity 0.20 and
   the icons are a whisper; with it they run at 0.50.

   THE BANNER INKS. The user asked for bright solid colour here, so the copy is
   coloured rather than plain, and every value is a flat colour - the headline
   phrase used to be a gradient clipped to the text, which is neither solid nor
   as legible as the ink at either end of it.

   Measured over the composite (veil 0.55 + plate 0.48), worst case = the clip's
   brightest sampled pixel in the band the copy occupies:

     --hero-ink          #f4f1e6   7.21:1  body paragraph
     --hero-violet-lift  #cdc2ff   4.95:1  headline
     --hero-magenta      #ff9ecb   4.35:1  headline phrase - 46px bold, so AA
                                           Large (3:1) is the bar it has to clear
     --hero-teal         #7fe3cf   5.59:1  slogan, and 4.85:1 as the ticker label
                                           at 0.7rem - AA at any size
     --hero-violet       #4c2ae0           FILL: eyebrow chip, both buttons
     --hero-fill         #0a7a68           FILL: note chip

   Two fills and four inks. The FILLS DID NOT FLIP when the panel did: an opaque
   chip is its own background, so its text is measured against the fill and never
   sees the clip - which is the whole point of setting them solid. White on
   --hero-violet is 7.74:1 and on --hero-fill 5.25:1, both unchanged from the
   cream panel. That also makes the two chips and the primary button the one part
   of this panel that needs no re-measuring if the clip is ever swapped.

   #0a7a68 is AA as a fill but only 4.28:1 as small text on the old ground, so it
   is used one way and not the other. */

.hero-banner {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  margin: 18px 0 0;
  border: 1px solid var(--border);
  border-radius: 24px;
  background-color: #16221a;
  background-image: linear-gradient(180deg, #1b2a20, #101a13);
  box-shadow: 0 18px 40px rgba(28,46,32,0.06);

  /* Declared here rather than in :root because they are banner copy colours,
     not site tokens - and .hero is a descendant, so it inherits all six. */
  --hero-ink: #f4f1e6;
  --hero-violet-lift: #cdc2ff;
  --hero-magenta: #ff9ecb;
  --hero-teal: #7fe3cf;
  --hero-violet: #4c2ae0;
  --hero-fill: #0a7a68;
}

/* Layer 0 - the clip. `cover` so it fills the panel at any aspect without
   letterboxing, which means the visible crop changes with the breakpoint: the
   panel is far wider than the 1936x1080 source on a desktop, so what shows is
   the vertical middle of the frame, and on a phone the horizontal middle. Both
   extremes were sampled before the veil below was sized.

   Inert - decoration, `aria-hidden` in the JSX, no controls - and not rendered
   at all under prefers-reduced-motion, so there is no paused state to style and
   none of the 4.1 MB is fetched. */
.hero-video {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  pointer-events: none;
}

/* Layer 1 - three soft colour fields, drifting. Inset negatively so the blobs
   can move without their edges ever entering the panel. */
.hero-banner::before {
  content: "";
  position: absolute;
  inset: -22%;
  z-index: 1;
  pointer-events: none;
  background:
    radial-gradient(closest-side, rgba(108,92,231,0.13), transparent) 10% 20% / 48% 54% no-repeat,
    radial-gradient(closest-side, rgba(10,122,104,0.13), transparent) 90% 26% / 44% 52% no-repeat,
    radial-gradient(closest-side, rgba(181,129,31,0.10), transparent) 62% 92% / 42% 48% no-repeat;
  animation: hero-wash 34s ease-in-out infinite alternate;
}

@keyframes hero-wash {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(2.5%, -2.5%, 0) scale(1.07); }
}

/* Layer 2 - the contrast guard. Static on purpose: if it moved with the wash it
   would stop being a guarantee.

   FLAT, and covering the whole panel, where the cream version was a radial that
   faded out by 70%. Two reasons it had to change shape as well as colour: the
   clip reaches the panel edge where the old cream background was uniform, and
   the motifs live out there, so a guard that governs only the middle governs
   neither. 0.55 of #101a13 - see the sampling note above. */
.hero-banner::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background: rgba(16,26,19,0.55);
}

/* Layer 3 - the dosage-form field, spread over the whole panel on the brick grid
   HERO_MOTIFS computes. It used to be masked out of the middle so it framed the
   copy; the user asked for even cover including the centre, so the mask is gone
   and every icon now crosses body copy at some point in its drift.

   That is what sets the opacity, and the sum flipped when the panel went dark.
   On cream the icons were the DARKEST thing under the copy and 0.10 was a
   ceiling; on the clip they are the PALEST thing under it, and the same crossing
   pulls the other way. Measured against the composited stack, worst case being
   the palest of the ten tints at the clip's brightest frame:

     0.50  motif 4.26:1 against its backdrop, body copy over it 7.21:1   <- used
     0.20  motif 1.79:1 - a whisper - body copy over it 4.63:1

   0.50 is only available because the plate (.hero::before) sits between the
   field and the words; without it the ceiling is the 0.20 row. Holds at every
   width with no per-breakpoint override. */
.hero-motifs {
  position: absolute;
  inset: 0;
  z-index: 3;
  overflow: hidden;
  pointer-events: none;
}

/* <Icon> draws at 1em, so font-size is the icon's size and the span is exactly
   1em square - which is why the negative margins centre it on its point without
   a translate, leaving `transform` free for the animations below.

   The ink arrives inline from ICON_TONES - the same pair the showcase tiles and
   feature cards use, so a syrup bottle here is the amber it is everywhere else on
   the site. */
.hero-motif {
  position: absolute;
  top: var(--motif-y);
  left: var(--motif-x);
  margin: -0.5em 0 0 -0.5em;
  font-size: clamp(2.4rem, 5.6vw, 4.6rem);
  line-height: 1;
  opacity: 0.5;

  /* Two animations, one job each: a full turn, and a slow float. Longhands
     rather than the shorthand because every value is a per-icon custom property
     and the pairs have to line up in the same order in both lists. */
  animation-name: hero-motif-spin, hero-motif-float;
  animation-duration: var(--motif-spin), var(--motif-float);
  animation-timing-function: linear, ease-in-out;
  animation-iteration-count: infinite, infinite;
  animation-direction: var(--motif-direction), alternate;
  animation-delay: var(--motif-delay), var(--motif-delay);
}

/* A full 360, so direction is a real choice rather than a wobble: half the field
   turns clockwise and half anticlockwise, from `--motif-direction`. */
@keyframes hero-motif-spin {
  to { rotate: 360deg; }
}

/* Separate property from the spin (`translate`, not `transform`), so the two
   animations compose instead of the second overwriting the first. */
@keyframes hero-motif-float {
  from { translate: 0 0; }
  to   { translate: 10px -14px; }
}

/* Both animated parts of the banner, off. The third moving thing - the clip - is
   not listed because <Hero /> does not render it under this preference at all,
   so there is nothing here to pause and nothing to fetch. */
@media (prefers-reduced-motion: reduce) {
  .hero-banner::before,
  .hero-motif { animation: none; }
}

/* ==== END: Hero banner ===================================================== */


/* ==== BEGIN: Hero ========================================================== */
/* Single centred promo column - the auth card that used to sit beside it now
   opens in a modal from the navbar. Horizontal padding is not decoration: inside
   the banner panel the column would otherwise touch the panel's border on a
   phone. */

.hero {
  position: relative;
  z-index: 4;
  text-align: center;
  max-width: 720px;
  margin: 0 auto;
  padding: 56px 24px 52px;
}

/* The copy's own backdrop - see THE PLATE in the banner note above. z-index -1
   inside .hero, which is itself z-index 4: that paints it at the bottom of
   .hero's own stacking context, so it lands above the motif field (layer 3) and
   below these words. It is the one position from which it can darken an icon
   that has drifted under a line without dimming the field anywhere else.

   THE INSET AND THE STOP ARE LOAD-BEARING, and the reason is that this is an
   ELLIPSE over a block of text that is nearly a rectangle. The corners of a wide
   line sit far outside an ellipse drawn to the same box: at the first values
   tried here (inset -12% -8%, solid to 62%) the headline was 521px inside a
   407px solid region, so its first and last words sat at alpha 0.12 instead of
   0.48 and measured 2.41:1. Sized from the text boxes outward instead, the solid
   ellipse now contains every line at every width. If the copy column is ever
   made wider, re-measure - do not assume this still clears.

   0.55 out to 80% of the radius, feathered to nothing after, so it has no
   visible edge. Worst case measured over the clip's brightest frames, each line
   sampled on an 18-point grid, with the palest of the ten tints crossing it:

     body paragraph 7.21:1    slogan 5.59:1    ticker 4.85:1
     headline 4.95:1          headline phrase 4.35:1 (46px bold, AA Large = 3:1)

   The icons are NOT starved by it: 4.26:1 against their own backdrop out at the
   panel corner, where the field is what the eye reads. */
.hero::before {
  content: "";
  position: absolute;
  inset: -35% -30%;
  z-index: -1;
  pointer-events: none;
  background: radial-gradient(
    closest-side,
    rgba(12,20,16,0.55),
    rgba(12,20,16,0.55) 80%,
    rgba(12,20,16,0) 100%
  );
}

/* The count chip. Filled solid rather than tinted: it sits at the top of the
   column where small uppercase text would otherwise read against whatever the
   clip and the drift happen to be doing. An opaque fill takes both out of the
   measurement entirely - white on --hero-violet is 7.74:1 wherever the chip
   lands, and that figure survived the panel going dark untouched. */
/* line-height, 2026-08-31. This pill wraps — three lines at 360px, two at 768
   and above — and with none set it inherited `normal`, which is about 1.2 and
   is far too tight for tracked uppercase: the caps of one line all but touch
   the baseline above. 1.35 rather than the 1.5 the body copy takes, because
   capitals have no descenders to clear and a pill this size would look loose
   at prose leading. Same figure for every tracked-uppercase run below. */
.eyebrow {
  display: inline-block;
  color: #fff;
  background: var(--hero-violet);
  border: none;
  padding: 0.4375rem 0.875rem;
  border-radius: 999px;
  font-size: 0.78rem;
  font-weight: 700;
  line-height: 1.35;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  margin-bottom: 18px;
  box-shadow: 0 6px 16px rgba(76,42,224,0.28);
}

/* Ambient, cycling company name — see <HeroTicker /> in app.jsx. The name fades
   and lifts in on every remount; the key change on .hero-ticker-name is what
   forces that remount each rotation, same trick as .showcase-progress. */
.hero-ticker {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  margin-bottom: 18px;
  font-size: 0.85rem;
}

/* Solid teal at full strength. It was --text-dim at 0.7 opacity, which put it at
   roughly 3.9:1 on the ground and under AA - faint reads as unfinished next to a
   coloured company name, not as restraint. */
.hero-ticker-label {
  color: var(--hero-teal);
  text-transform: uppercase;
  letter-spacing: 0.6px;
  font-size: 0.7rem;
  font-weight: 700;
  line-height: 1.35;
}

/* The colour arrives inline from the company's own palette, so the line previews
   that company's page. It reads `tint` rather than `ink` since the panel went
   dark - the same swap the motifs made, and for the same reason: all ten inks
   are deep saturated values that disappear into the clip, while the ten tints
   are pale, carry the same hue, and clear AA over the plate. This rule sets
   weight and size only. */
.hero-ticker-name {
  font-size: 0.95rem;
  font-weight: 800;
  line-height: 1.35;
  letter-spacing: 0.2px;
  animation: hero-ticker-in 0.5s ease;
}

/* No plate rule here on purpose: this line is the company name alone, as is the
   navbar crumb — neither carries a company mark, and a photograph was tried in
   both and taken back out. The animal belongs to .company-card-plate and
   .company-hero-plate. */

@keyframes hero-ticker-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .hero-ticker-name { animation: none; }
}

/* Two solid inks, not one ink and one gradient. Both are set here rather than
   inherited from --text: the headline is the brightest thing in the banner and
   near-black would have read as the odd line out once everything around it was
   coloured. */
.hero h1 {
  color: var(--hero-violet-lift);
  font-size: 2.9rem;
  font-weight: 800;
  line-height: 1.1;
  margin: 0 0 18px;
  letter-spacing: -0.5px;
}

/* The phrase inside the headline. It carried a violet-to-teal gradient clipped to
   the glyphs, which meant the middle of the phrase was whatever lay between two
   accents - around 3.5:1 at the crossover, and washed out at that. A flat magenta
   is both brighter and measurable: 5.08:1 over the plate, so it now clears AA at
   any size rather than leaning on AA Large as it did on the cream panel. */
.hero h1 span {
  color: var(--hero-magenta);
}

/* Full ink, not --text-dim. Three coloured lines above it need one plain block
   to push against, and dim grey under bright copy reads as switched off. */
.hero p {
  color: var(--hero-ink);
  font-size: 1.05rem;
  line-height: 1.7;
  max-width: 560px;
  margin: 0 auto 30px;
}

/* The slogan, between the headline and the paragraph. Full --text rather than
   the dim the paragraph below it takes: three sizes of ink down the column -
   headline, slogan, body - is what keeps it a subhead instead of a second
   sentence of the same block. Not uppercased; it arrives in title case with
   full stops, and shouting it would fight the headline.

   Two classes deep on purpose: `.hero p` above sets colour, size and margin, and
   a single class loses to it on specificity - the line would come out --text and
   1.05rem however this rule is written.

   Teal is the third hue of the banner and the only one that carries a whole line
   of small text on the open ground, so it is the lightest of the three inks now
   that the panel is dark - 6.63:1 over the plate, AA at any size, including the
   0.95rem it drops to on a small phone. */
.hero .hero-slogan {
  color: var(--hero-teal);
  font-size: 1.12rem;
  font-weight: 700;
  letter-spacing: 0.2px;
  line-height: 1.5;
  max-width: 520px;
  margin: -2px auto 20px;
}
.hero-actions {
  display: flex;
  gap: 14px;
  align-items: center;
  justify-content: center;
}

/* Both buttons, scoped to the banner so the site-wide ones are untouched.

   The primary button is normally an --accent gradient. Here it is flat
   --hero-violet: it is the one gradient the banner had left once the headline
   went solid, and it was also the lighter violet the eyebrow chip used to be, so
   flattening it settles the colour and the finish in one go. White on it is
   7.74:1, up from about 4.9 over the gradient's first stop.

   The secondary button keeps `.btn`'s white fill but not its hairline border and
   body ink, which are right in a toolbar and much too quiet standing next to a
   filled violet button under a magenta headline - it read as disabled. */
.hero-actions .btn-primary {
  background: var(--hero-violet);
  box-shadow: 0 8px 20px rgba(76,42,224,0.30);
}

@media (hover: hover) {
.hero-actions .btn-primary:hover { filter: brightness(1.12); }
}

.hero-actions .btn:not(.btn-primary) {
  border: 2px solid var(--hero-violet);
  color: var(--hero-violet);
  font-weight: 700;
}

/* `border-color` repeats the rule above and is still load-bearing: `.btn:hover`
   sets it to --accent at the same specificity, so dropping this line turns the
   outline the site purple on hover instead of the headline violet. */
@media (hover: hover) {
.hero-actions .btn:not(.btn-primary):hover {
  border-color: var(--hero-violet);
  background: rgba(76,42,224,0.07);
}
}

/* Bottom chip, filled to match the eyebrow at the top. It sits at the bottom of
   the column where the plate has begun to feather, so the same reasoning applies
   with more force: its own opaque fill is the background its text is measured
   against (5.25:1), not the clip behind it. */
.hero-note {
  display: inline-block;
  margin-top: 20px;
  padding: 0.4375rem 0.9375rem;
  border-radius: 999px;
  background: var(--hero-fill);
  color: #fff;
  font-size: 0.85rem;
  font-weight: 700;
  /* Wraps to two lines from 360px up; see .eyebrow for the 1.35 vs 1.5 split.
     This one is sentence case, so it takes prose leading. */
  line-height: 1.55;
  letter-spacing: 0.2px;
  box-shadow: 0 6px 16px rgba(10,122,104,0.24);
}

/* ==== END: Hero ============================================================ */


/* ==== BEGIN: Company directory ============================================= */
/* The ten companies on the home page. Each card is a link to that company's
   landing page, and wears that company's colour set and display face — so the
   directory previews the ten pages instead of listing ten identical rows.
   All the --co-* and --mk-* properties come from companyStyle() inline. */

.company-strip {
  text-align: center;
  padding: 8px 0 44px;
}

.company-strip h2 {
  font-size: 1.5rem;
  margin: 0 0 8px;
}

.company-strip > p {
  color: var(--text-dim);
  font-size: 0.95rem;
  line-height: 1.55;
  margin: 0 0 24px;
}

.company-directory {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(285px, 1fr));
  gap: 14px;
  text-align: left;
}

/* The count spans both columns on its own row, so a long legal name wrapping
   to three lines cannot push it out of alignment with the neighbouring cards. */
.company-card {
  display: grid;
  grid-template-columns: 60px 1fr;
  grid-template-areas:
    "mono  body"
    "count count";
  gap: 0 14px;
  align-items: center;
  padding: 16px;
  border-radius: var(--radius);
  border: 1px solid var(--co-edge);
  background: var(--card);
  color: var(--text);
  transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}

@media (hover: hover) {
.company-card:hover {
  transform: translateY(-2px);
  box-shadow: var(--shadow);
  border-color: var(--co-ink);
}
}

.company-card:focus-visible {
  outline: 2px solid var(--co-ink);
  outline-offset: 2px;
}

/* The animal photograph's frame, in the grid cell the monogram tile used to
   fill — same 60px square, same radius, same tinted border, so the directory's
   geometry is untouched. `overflow: hidden` is what the photo needs and the
   letters did not: without it the image's square corners sit outside the
   rounded frame. The --mk-* font properties are gone with the letters. */
.company-card-plate {
  grid-area: mono;
  width: 60px;
  height: 60px;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  border-radius: 12px;
  background: var(--co-tint);
  border: 1px solid var(--co-edge);
}

.company-card-body {
  grid-area: body;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* The wordmark is set in the company's own display face — same idea as the
   brand mark on a product card: type standing in for a logo that has no file.

   overflow-wrap is load-bearing, not defensive. "PHARMACEUTICALS" in Segoe
   Script measures 173px against a 125px body column on a 264px-wide viewport,
   and one unbreakable word pushed the whole page into horizontal scroll.

   `anywhere` rather than `break-word`, and the difference is the whole fix:
   both break a word that cannot fit on its own line, but only `anywhere`
   reduces the element's min-content width. `break-word` left min-content at the
   full word, so a flex item sized to fit-content still resolved wider than its
   container and overflowed anyway. Neither breaks a word that fits, so normal
   widths are unaffected either way. */
.company-card-mark {
  overflow-wrap: anywhere;
  font-family: var(--mk-font);
  font-weight: var(--mk-weight);
  font-style: var(--mk-style);
  letter-spacing: var(--mk-tracking);
  text-transform: var(--mk-transform);
  font-size: calc(1.2rem * var(--mk-scale));
  line-height: 1.15;
  color: var(--co-ink);
}

/* The motto pill, where the PPB number used to sit. `align-self: flex-start`
   keeps it hugging its text rather than stretching the body column, exactly as
   before — but the tracking drops from 0.6px to 0.2px, because 0.6px was set
   for a five-character code and spaces out a sentence like a shop sign. Long
   mottos wrap inside the pill; the count below it spans its own grid row, so a
   two-line pill cannot pull the cards out of alignment. */
.company-card-motto {
  align-self: flex-start;
  margin-top: 8px;
  padding: 0.1875rem 0.5625rem;
  border-radius: 6px;
  background: var(--co-tint);
  color: var(--co-ink);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.2px;
  line-height: 1.35;
}

/* -- The animal photographs ------------------------------------------------ */
/* One rule for every logo on the site: the directory card's 60px plate and all
   five hero plates. The files are opaque landscape photographs of very
   different proportions — 813x450 for the lion, 318x331 for the rhino — so
   `cover` is doing real work here, not sitting in as a default. It fills the
   square from the centre of the frame, which is where the animal is in all ten.

   `display: block` because an inline image sits on the text baseline and would
   leave a few pixels of tint under it inside a plate that has no text at all. */
.company-logo {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

.company-card-count {
  grid-area: count;
  margin-top: 13px;
  padding-top: 0.6875rem;
  border-top: 1px solid var(--border);
  font-size: 0.78rem;
  font-weight: 600;
  line-height: 1.4;
  color: var(--text-dim);
}

/* -- Company search hits --------------------------------------------------- */
/* Searching a company name surfaces its page, not only its products. Without
   this, the one search term most likely to find a company whose catalogue is
   still empty returns nothing at all. */

.company-hits {
  padding: 0 0 30px;
  text-align: left;
}

.company-hits h2 {
  margin: 0 0 12px;
  font-size: 1.05rem;
}

.company-hit-row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.company-hit {
  display: inline-flex;
  align-items: center;
  gap: 0.5625rem;
  padding: 0.4375rem 0.9375rem 0.4375rem 0.5rem;
  border-radius: 999px;
  border: 1px solid var(--co-edge);
  background: var(--co-tint);
  color: var(--co-ink);
  font-size: 0.8rem;
  font-weight: 700;
}

@media (hover: hover) {
.company-hit:hover { filter: brightness(0.97); }
}

.company-hit span {
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 999px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--card);
  font-family: var(--mk-font);
  font-size: calc(0.68rem * var(--mk-scale));
  line-height: 1;
}

/* ==== END: Company directory =============================================== */


/* ==== BEGIN: Showcase ====================================================== */
/* Home-page browse aid: dosage forms and therapeutic categories, with a
   spotlight that rotates through whichever panel is open.

   The category tiles and the spotlight take their colour from the --tc-* set
   that <Showcase /> puts on each <li> — the same family colours the product
   plates use, so a colour learned here still means the same thing in the grid.
   Dosage forms carry no --tc-*, which is why every colour below is written as
   var(--tc-x, fallback): categories come out tinted, forms neutral, from one
   rule rather than two. */

.showcase {
  padding: 8px 0 48px;
  text-align: center;
}

.showcase h2 {
  margin: 0 0 8px;
  font-size: 1.5rem;
}

.showcase-lead {
  margin: 0 0 20px;
  color: var(--text-dim);
  font-size: 0.95rem;
  line-height: 1.55;
}

/* -- Panel switch (a segmented control) ------------------------------------ */

.showcase-tabs {
  display: inline-flex;
  gap: 4px;
  margin-bottom: 20px;
  padding: 4px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--bg-soft);
}

.showcase-tab {
  padding: 0.5rem 1.0625rem;
  border: none;
  border-radius: 999px;
  background: none;
  color: var(--text-dim);
  font-size: 0.85rem;
  font-weight: 700;
  transition: all 0.15s ease;
}

@media (hover: hover) {
.showcase-tab:hover { color: var(--text); }
}

.showcase-tab.active {
  background: var(--card);
  color: var(--text);
  box-shadow: 0 1px 3px rgba(28,46,32,0.12);
}

/* -- Spotlight ------------------------------------------------------------- */
/* No opacity on any text in here. The ink/tint pairs clear AA at 5.6:1 at
   worst, and knocking the detail line back to 0.8 alpha drops it under 4.5:1 —
   size is what separates the two lines instead. */

.showcase-spot {
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  gap: 18px;
  max-width: 640px;
  margin: 0 auto 20px;
  padding: 20px 24px;
  border-radius: var(--radius);
  border: 1px solid var(--tc-edge, var(--border));
  background: var(--tc-tint, var(--bg-soft));
  color: var(--tc-ink, var(--text));
  text-align: left;
}

.showcase-spot-icon {
  flex-shrink: 0;
  font-size: 2rem;
  line-height: 1;
}

/* Every drawn icon (<Icon />) — ten dosage forms, three feature cards, five
   contact channels. They carry width/height in `em` as attributes, so the box
   follows the font-size wherever they are dropped; this only fixes how they
   sit. display:block removes the baseline gap that would otherwise push an
   icon off centre from its label, and is why none of the spans that host one
   need a display of their own. */
.ui-icon {
  display: block;
  flex-shrink: 0;
}

/* The chip's own 0.76rem would leave the icon noticeably smaller than the
   label it sits beside. */
.filter-chip .ui-icon {
  width: 1.2em;
  height: 1.2em;
}

.showcase-spot-body {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}

.showcase-spot-label {
  font-size: 1.05rem;
  font-weight: 700;
}

.showcase-spot-detail {
  font-size: 0.82rem;
  line-height: 1.45;
}

.showcase-spot-count {
  margin-left: auto;
  flex-shrink: 0;
  font-size: 1.3rem;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.showcase-spot-count small {
  display: block;
  font-size: 0.62rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.8px;
}

/* Runs for exactly one rotation. --rot is passed in from SHOWCASE_ROTATE_MS so
   the bar and the timer cannot drift apart, and the element is keyed on the
   spotlight index so remounting restarts the animation on every tick. */
@keyframes showcase-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.showcase-progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: currentColor;
  opacity: 0.4;
  transform-origin: left;
  animation: showcase-progress var(--rot) linear forwards;
}

/* Nothing rotates under this preference, so a progress bar would sit at 0
   forever. <Showcase /> skips the timer for the same reason. */
@media (prefers-reduced-motion: reduce) {
  .showcase-progress { display: none; }
}

/* -- Tiles ---------------------------------------------------------------- */

.showcase-tiles {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.showcase-tiles li { display: flex; }

.showcase-tile {
  display: inline-flex;
  align-items: center;
  gap: 0.4375rem;
  padding: 0.5rem 0.8125rem;
  border-radius: 999px;
  border: 1px solid var(--tc-edge, var(--border));
  background: var(--tc-tint, var(--card));
  color: var(--tc-ink, var(--text));
  font-size: 0.8rem;
  font-weight: 600;
  text-align: left;
  transition: all 0.15s ease;
}

/* A stroked icon reads smaller than an emoji of the same em size, so the tile
   runs slightly larger than the 0.95rem the emoji set used to. */
.showcase-tile-icon { font-size: 1.05rem; line-height: 1; }

.showcase-tile-count {
  min-width: 1.125rem;
  padding: 0 0.3125rem;
  border-radius: 999px;
  background: rgba(255,255,255,0.72);
  font-size: 0.68rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  text-align: center;
}

/* Which tile the spotlight is on. A border swap rather than a ring or a nudge:
   this changes every 2.8s beside a wall of other chips, and anything that
   alters size would reflow the whole row on every tick. */
.showcase-tile.spotlit:not(.active) { border-color: var(--tc-ink, var(--accent)); }

@media (hover: hover) {
button.showcase-tile:hover { border-color: var(--tc-ink, var(--accent)); }
}

/* Selected — filled. White on every --tc-ink clears AA, and on the --accent
   fallback it is 4.86:1. */
.showcase-tile.active {
  background: var(--tc-ink, var(--accent));
  border-color: transparent;
  color: #fff;
}

.showcase-tile.active .showcase-tile-count {
  background: rgba(255,255,255,0.22);
  color: #fff;
}

/* Rendered as a span while the catalogue is empty — there is nothing to filter,
   so it must not look or behave like a control. */
.showcase-tile.is-static { cursor: default; }

/* ==== END: Showcase ======================================================== */


/* ==== BEGIN: Company landing page ========================================== */
/* One page per company at #/company/<slug>. companyStyle() sets --co-* and
   --mk-* on .company-page and also overrides --accent, which is what recolours
   the buttons, chips, focus rings and pager inside it per company.

   Five hero treatments — split, banner, stack, rule, panel — two companies
   each, and no two sharing a treatment share a colour, a face or an animal.
   The shared pieces are defined once here; each layout-* block only arranges
   them. All five frame the animal photograph in .company-hero-plate and print
   the company's motto where its PPB number used to be. */

.company-page { padding-top: 6px; }

.company-hero {
  margin: 0 0 34px;
  border-radius: var(--radius);
  border: 1px solid var(--co-edge);
  background: var(--card);
  overflow: hidden;
}

.company-hero-text { min-width: 0; }

.company-hero-mark {
  margin: 0;
  /* Same reason as .company-card-mark, including why it is `anywhere` — a
     display face at this size has single words wider than a phone, and the
     collapsed heroes size their text column to fit-content. */
  overflow-wrap: anywhere;
  font-family: var(--mk-font);
  font-weight: var(--mk-weight);
  font-style: var(--mk-style);
  letter-spacing: var(--mk-tracking);
  text-transform: var(--mk-transform);
  font-size: calc(2.5rem * var(--mk-scale));
  line-height: 1.1;
  color: var(--co-ink);
}

.company-hero-status {
  display: inline-block;
  padding: 0.25rem 0.75rem;
  border-radius: 999px;
  background: var(--co-tint);
  border: 1px solid var(--co-edge);
  color: var(--co-ink);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.4px;
}

/* One labelled pair on a hero — a <dl> with a small caps label over a value in
   the company's ink. Two callers, and that is why the name is `fact` rather than
   anything more specific: it carries the motto in four of the five layouts, and
   in `panel` a second copy carries the catalogue count. It was `.company-reg`
   until 2026-08-04, which stopped being true when the PPB number came off the
   page — the rule never held a registration number again. */
.company-fact {
  display: flex;
  align-items: baseline;
  gap: 10px;
  margin: 16px 0 0;
}

.company-fact dt {
  margin: 0;
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.7px;
  color: var(--text-dim);
}

/* The motto, not a registration number: 0.5px of tracking was set for
   "PPB/G/1671" and reads as a spaced-out shop sign on a sentence. `line-height`
   is here because a long motto can now wrap, which a code never did. */
.company-fact dd {
  margin: 0;
  font-size: 0.92rem;
  font-weight: 700;
  letter-spacing: 0.1px;
  line-height: 1.4;
  color: var(--co-ink);
}

/* The animal photograph's frame, used by all five hero treatments — the shared
   box the old monogram span sat in, minus the type properties. `overflow` is
   what clips the photo's square corners to the 16px radius. */
.company-hero-plate {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  overflow: hidden;
  width: 118px;
  height: 118px;
  border-radius: 16px;
  background: var(--co-tint);
  border: 1px solid var(--co-edge);
}

/* -- layout: split — photo tile left, copy right -------------------------- */
.company-hero.layout-split {
  display: flex;
  align-items: center;
  gap: 26px;
  padding: 28px;
}

.company-hero.layout-split .company-hero-status { margin-bottom: 12px; }

/* -- layout: banner — tinted band, copy left, photo tile right ------------ */
/* The band used to hold an oversized monogram watermark, absolutely positioned
   and clipped by .company-hero's overflow at 0.15 opacity. A photograph cannot
   play that part — at that opacity it is a grey smudge, not an animal — so the
   watermark is gone and the plate sits on the right instead, mirroring split's
   on the left. The band keeps its own identity through the gradient.
   `space-between` is what puts the plate on the far edge; the two former
   watermark helpers (position: relative here, z-index on the text) went with
   the layer they were stacking against. */
.company-hero.layout-banner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 26px;
  padding: 34px 30px;
  background: linear-gradient(100deg, var(--co-tint), var(--card) 72%);
}

.company-hero.layout-banner .company-hero-status { margin-bottom: 12px; }

/* -- layout: stack — everything centred ----------------------------------- */
.company-hero.layout-stack {
  padding: 36px 28px;
  text-align: center;
}

.company-hero.layout-stack .company-hero-text {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.company-hero.layout-stack .company-hero-plate { margin-bottom: 18px; }
.company-hero.layout-stack .company-hero-status { margin-top: 14px; }
.company-hero.layout-stack .company-fact { justify-content: center; }

/* -- layout: rule — thick left rule, motto as a stamp --------------------- */
.company-hero.layout-rule {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  /* Left padding is 0 on purpose — the border IS the left edge, and the text
     supplies its own inset. */
  padding: 30px 28px 30px 0;
  border-left: 10px solid var(--co-ink);
}

.company-hero.layout-rule .company-hero-text { padding-left: 26px; }
.company-hero.layout-rule .company-hero-status { margin-top: 14px; }

/* The plate is inside the text column here, so the column's 26px inset off the
   thick rule aligns it with the wordmark below rather than a margin of its own. */
.company-hero.layout-rule .company-hero-plate { margin-bottom: 16px; }

/* The stamp holds a motto now, not a registration number, and that is why
   `flex-shrink` is 1 with a max-width rather than 0: a sentence in a box that
   refuses to shrink pushes the wordmark beside it into overflow, which
   "PPB/BM" never could. The width caps it before it takes half the band. */
.company-hero-stamp {
  flex-shrink: 1;
  max-width: 230px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 14px 20px;
  border: 2px dashed var(--co-edge);
  border-radius: 10px;
  background: var(--co-tint);
  text-align: center;
}

.company-hero-stamp-label {
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 1px;
  color: var(--text-dim);
}

/* Same reasoning as .company-fact dd — 1px of tracking was set for a code. It
   also has to wrap now: "All in one, one in all!" is not "PPB/BM". */
.company-hero-stamp-value {
  font-size: 1.05rem;
  font-weight: 800;
  letter-spacing: 0.1px;
  line-height: 1.3;
  color: var(--co-ink);
}

/* -- layout: panel — copy left, motto card right -------------------------- */
.company-hero.layout-panel {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 26px;
  align-items: center;
  padding: 30px;
}

.company-hero-panel {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  min-width: 215px;
  padding: 18px 20px;
  border-radius: 12px;
  background: var(--co-tint);
  border: 1px solid var(--co-edge);
}

/* Smaller than the 118px plate the other four layouts use: this one sits inside
   a 215px card beside the motto rather than standing on its own. */
.company-hero-panel .company-hero-plate {
  width: 84px;
  height: 84px;
  border-radius: 12px;
  margin-bottom: 10px;
}

.company-hero-panel .company-fact {
  flex-direction: column;
  align-items: flex-start;
  gap: 1px;
  margin-top: 10px;
}

/* -- Page footer nav & the pending-catalogue empty state ------------------- */

.company-footer-nav { padding: 0 0 56px; }

/* ==== END: Company landing page ============================================ */


/* ==== BEGIN: Disclosure ==================================================== */
/* The shared show/hide section behind the category rail, the showcase's tile
   list, the fifty-category index and the profile band's four copy rows. One
   button, one region — see <Disclosure /> for why the region is `hidden` rather
   than unmounted. */

/* flex-start rather than space-between, because the chevron is a THIRD child
   now (it used to sit inside disclosure-meta and got clipped there — see the
   comment in the Disclosure component). With three items, space-between would
   strand the summary in the middle of the row; disclosure-meta takes
   margin-left: auto instead, which keeps the heading left and the summary and
   chevron together on the right exactly as before. */
.disclosure-toggle {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 0.75rem;
  padding: 0.6875rem 0.9375rem;
  border-radius: 0.625rem;
  border: 1px solid var(--border);
  background: var(--bg-soft);
  color: var(--text);
  font-size: 0.85rem;
  font-weight: 700;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease;
}

@media (hover: hover) {
.disclosure-toggle:hover { border-color: var(--accent); }
}

/* Square off the bottom while open, so the button and the list it reveals read
   as one panel rather than two stacked boxes. */
.disclosure.is-open .disclosure-toggle {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
  border-bottom-color: transparent;
}

.disclosure-heading {
  text-transform: uppercase;
  letter-spacing: 0.9px;
  font-size: 0.72rem;
  line-height: 1.35;
}

/* Carries the summary — for a filter, the current selection. Allowed to shrink
   and ellipsise, because a long category name must not push the chevron off the
   end of the button. */
/* Holds only the summary text now, so it is a plain block that ellipsises
   rather than a flex row — text-overflow works on a text container and never
   worked on the flex children this used to have. margin-left: auto is what
   pushes it and the chevron to the right edge under flex-start above. */
.disclosure-meta {
  display: block;
  margin-left: auto;
  min-width: 0;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.disclosure-chevron {
  flex-shrink: 0;
  font-size: 0.7rem;
  transition: transform 0.2s ease;
}

.disclosure.is-open .disclosure-chevron { transform: rotate(180deg); }

/* No display property of its own, deliberately: the `hidden` attribute's UA
   rule is display:none at very low specificity, and any class rule setting a
   display here would silently beat it and leave the region visible when
   collapsed. Stated explicitly as well, so a later edit cannot reintroduce that. */
.disclosure-region {
  padding: 12px 2px 2px;
}

.disclosure-region[hidden] { display: none; }

/* ==== END: Disclosure ====================================================== */


/* ==== BEGIN: Category rail ================================================= */
/* Filter chips for the page in view, behind a <Disclosure />. Only categories
   that hold a product get a chip — a rail of fifty, forty of them empty, is a
   wall rather than a filter. Counts come from the scope *before* the category
   filter, so selecting one does not zero every other chip. */

.category-rail { margin: 0 0 22px; }

/* An active filter tints the closed header, so a collapsed rail still shows at
   a glance that the grid below it is filtered. The summary text names which. */
.category-rail.is-filtered .disclosure-toggle {
  border-color: var(--accent);
  background: var(--card);
}

.category-rail.is-filtered .disclosure-meta { color: var(--accent); }

.category-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.category-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.4375rem;
  padding: 0.4375rem 0.75rem;
  border-radius: 999px;
  border: 1px solid var(--tc-edge);
  background: var(--card);
  color: var(--text);
  font-size: 0.8rem;
  font-weight: 600;
  text-align: left;
  transition: all 0.15s ease;
}

@media (hover: hover) {
.category-chip:hover {
  background: var(--tc-tint);
  color: var(--tc-ink);
}
}

.category-chip.active {
  background: var(--tc-tint);
  border-color: var(--tc-ink);
  color: var(--tc-ink);
}

/* The "All categories" chip has no family colour of its own, so it borrows the
   neutral border and the sage band for its active state. */
.category-chip.all { border-color: var(--border); }

.category-chip.all.active {
  background: var(--surface-band);
  border-color: var(--surface-band);
  color: var(--text);
}

.category-chip-count {
  min-width: 1.125rem;
  padding: 0 0.3125rem;
  border-radius: 999px;
  background: var(--bg-soft);
  font-size: 0.68rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  text-align: center;
}

.category-chip.active .category-chip-count { background: rgba(255,255,255,0.72); }

/* ==== END: Category rail =================================================== */


/* ==== BEGIN: Category index (all fifty) ==================================== */
/* What a company page shows in place of a catalogue: the frame the uploaded
   products will be grouped into, already carrying its family colours. Collapsed
   by default — fifty chips open is a lot of page to scroll past. */

.category-index {
  margin: 18px auto 22px;
  max-width: 720px;
  text-align: left;
}

/* The toggle, its meta line and the chevron rotation all moved to .disclosure-*
   when <CategoryIndex /> was rewritten on top of <Disclosure />, which the
   category rail and the showcase tiles also use. */

.category-index-list {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.category-index-list li {
  padding: 0.3125rem 0.6875rem;
  border-radius: 999px;
  background: var(--tc-tint);
  border: 1px solid var(--tc-edge);
  color: var(--tc-ink);
  font-size: 0.74rem;
  font-weight: 600;
}

/* ==== END: Category index (all fifty) ====================================== */


/* ==== BEGIN: Category groups =============================================== */
/* Products grouped under therapeutic category headings, inside one page of the
   pager. The heading rule takes the category's family colour, so a group reads
   as the same colour as the plates of the cards beneath it.

   `.category-group` itself has no rule and does not need one: the gap between
   groups comes from this flex column, and the element's job is to carry the
   category's --tc-* custom properties down to its heading. It is one of four
   class names in the JSX with no stylesheet rule, all intentional — the others
   are `.showcase-panel` (tabpanel wrapper, block children), `.showcase-tile-label`
   (a span inside a flex tile) and the runtime-composed `layout-` prefix, which
   lands on the .layout-split/-banner/-stack/-rule/-panel rules further down.
   A class-name audit will list these; they are the known-good set. */

.category-groups {
  display: flex;
  flex-direction: column;
  gap: 30px;
}

.category-group-head {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin-bottom: 14px;
  padding-bottom: 8px;
  border-bottom: 2px solid var(--tc-edge);
}

.category-group-head h3 {
  margin: 0;
  font-size: 1.02rem;
  letter-spacing: -0.1px;
  color: var(--tc-ink);
}

.category-group-count {
  margin-left: auto;
  white-space: nowrap;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--text-dim);
}

/* ==== END: Category groups ================================================= */


/* ==== BEGIN: Pager ========================================================= */
/* 1 · 2 · 3 · 4 … with Prev and Next. pageNumbers() inserts the ellipses, so a
   forty-page catalogue still renders seven-odd buttons. The active button uses
   --accent, which a company page has overridden with its own colour. */

.pager {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 34px;
  padding-top: 22px;
  border-top: 1px solid var(--border);
}

.pager-list {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.pager-num,
.pager-step {
  min-width: 2.375rem;
  height: 2.375rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 0.75rem;
  border-radius: 9px;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-size: 0.88rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  transition: all 0.15s ease;
}

@media (hover: hover) {
.pager-num:hover:not(.active),
.pager-step:hover:not(:disabled) { border-color: var(--accent); }
}

.pager-num.active {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
  cursor: default;
}

.pager-step:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.pager-gap {
  padding: 0 4px;
  font-weight: 700;
  color: var(--text-dim);
}

/* ==== END: Pager =========================================================== */


/* ==== BEGIN: Products — toolbar ============================================ */

.products { padding-bottom: 72px; }

.toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 16px;
  margin-bottom: 24px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--border);
}

/* Flex so the active-filter chip can sit beside the count and wrap under it on
   a narrow toolbar. */
.toolbar-count {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.625rem;
  font-size: 0.9rem;
  color: var(--text-dim);
}

.toolbar-count strong { color: var(--text); }

/* Shows that a dosage-form filter is on, and clears it. The showcase tile that
   set it is usually scrolled off the top by the time you are reading the grid,
   so its active state is not visible here. */
.filter-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  padding: 0.25rem 0.625rem;
  border-radius: 999px;
  border: 1px solid var(--accent);
  background: var(--card);
  color: var(--text);
  font-size: 0.76rem;
  font-weight: 700;
}

@media (hover: hover) {
.filter-chip:hover { background: var(--bg-soft); }
}

.filter-chip-x { font-size: 0.7rem; }

.toolbar-controls {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}

/* <label> wrapping a caption and a <select>. The chevron is a pseudo-element
   so no image or data URI is needed. */
.select-wrap {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.82rem;
  color: var(--text-dim);
  font-weight: 600;
}

.select-wrap::after {
  content: "▾";
  position: absolute;
  right: 12px;
  font-size: 0.7rem;
  color: var(--text-dim);
  pointer-events: none;
}

.select-wrap select {
  appearance: none;
  -webkit-appearance: none;
  padding: 0.5rem 1.875rem 0.5rem 0.75rem;
  border-radius: 9px;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-family: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  outline: none;
  cursor: pointer;
}

.select-wrap select:focus { border-color: var(--accent); }

/* ==== END: Products — toolbar ============================================== */


/* ==== BEGIN: Products — grid & card ======================================== */
/* auto-fill, not auto-fit: when a filter narrows the results to one or two
   products the cards keep their size instead of stretching across the row. */

/* 218px min: the card is dense but narrow — the widest fixed content is the
   "Dissolution / 86% at 45 min (buffer stage)" spec row.

   THIS RULE STOPS AT 1399px. From 1400 up the responsive section replaces it
   with a fixed four columns inside the derived page column — see "The page
   column on a wide screen".

   FOUR IS A HARD MAXIMUM BELOW 1400px TOO, and by arithmetic rather than by
   luck: .container caps the content box at 1160 - 48 = 1112px here, and a
   fifth track needs 5 x 218 + 4 x 14 = 1146px. The 1023px block drops the
   minimum to 205px, and the content box has narrowed by more than the track
   did by then, so the count only ever falls. Counted at every width from 320
   to 5120: 1, 2, 3, 4 and never 5. If either number here is ever raised, that
   subtraction is the one to redo. */
.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(218px, 1fr));
  gap: 14px;
}

/* The card's contents break on the CARD's width, not the viewport's, and that
   distinction matters: the same viewport produces very different card widths (a
   375px phone shows one ~343px card, while a 700px tablet shows three ~210px
   ones), so viewport queries get this backwards.

   THIS USED TO BE `container-type: inline-size` AND A PAIR OF @container BLOCKS.
   Those need Chromium 105, Safari 16 or Firefox 110 — a far higher floor than
   anything else on this page — and an unsupported @container is not degraded but
   SKIPPED, so a 226px card on a Safari 15 iPad kept the side-by-side spec layout
   and broke "86% at 45 min (buffer stage)" mid-parenthetical. The card now
   measures itself in useCardWidthClass and carries .is-narrow / .is-very-narrow.
   The measured thresholds and every declaration they govern are unchanged; only
   what decides when they apply has moved.

   container-type is GONE rather than left in place. It also applies containment,
   and a property kept for a side effect nobody names is what the next edit trips
   over. Nothing inside the card is absolutely positioned against it — the only
   such descendant, .pack-image-cue, resolves against .pack-image, which sets its
   own position: relative — so removing it moves nothing.

   Verified by measuring the card's internals at six viewports before and after,
   on a browser that supports both mechanisms: identical at every one. */
.product-card {
  display: flex;
  flex-direction: column;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  transition: box-shadow 0.15s ease, transform 0.15s ease;
}

@media (hover: hover) {
.product-card:hover {
  box-shadow: var(--shadow);
  transform: translateY(-2px);
}
}

/* -- The card under the pointer -------------------------------------------- */
/* Asked for on 2026-08-15: the whole card inverts to a dark ground with white
   text while the pointer is on it.

   BEHIND @media (hover: hover), which is not optional. A touch browser with no
   pointer fires :hover on tap and then LEAVES IT APPLIED until something else
   is tapped, so on a phone the last card touched would stay inverted while the
   customer scrolled — indistinguishable from a selection state the app does not
   have. The query means a touch device never enters this block at all.

   rgba() over an opaque colour is the request as given, and it does something
   useful: the card ground shows through, so a card on the cream page body and
   the same card on a company landing page settle at slightly different darks
   and each stays keyed to its own page.

   THE PLATE HAS TO BE CLEARED SEPARATELY. It paints --tc-tint over the card's
   own background, so recolouring .product-card alone would have inverted the
   lower two thirds and left the brand plate pastel — the card would have looked
   half-painted rather than hovered.

   Everything below is one of two jobs: something that hardcodes a dark ink and
   now needs white, or something that fills itself with a light surface and now
   needs a translucent one. Nothing here changes a size, a weight or a position,
   so the card cannot reflow under the pointer and the grid cannot twitch. */
@media (hover: hover) {
  .product-card:not(.is-placeholder):hover {
    background: rgba(33, 41, 49, 0.8);
    border-color: rgba(33, 41, 49, 0.8);
    color: #fff;
  }

  /* The tint comes off so the dark shows through the plate too; the rule under
     it stays, at the weight a hairline has on a dark ground. */
  .product-card:not(.is-placeholder):hover .product-plate {
    background: none;
    border-bottom-color: rgba(255,255,255,0.22);
  }

  /* The mark included — so the five BRAND_INKS colours are off while hovered.
     Deliberate, and measured: rgba(33,41,49,0.8) composites to #4c5257 over the
     page body, and against that the five inks run 1.06:1 to 1.35:1 — every one
     of them illegible, the blue worst. White is 7.92:1. A legible white mark
     beats a colour-coded unreadable one, and the colour is back the instant the
     pointer leaves, which is the whole of what a hover state promises. */
  .product-card:not(.is-placeholder):hover .product-brandmark,
  .product-card:not(.is-placeholder):hover .product-company,
  .product-card:not(.is-placeholder):hover .product-generic,
  .product-card:not(.is-placeholder):hover .product-specs dt,
  .product-card:not(.is-placeholder):hover .product-specs dd,
  .product-card:not(.is-placeholder):hover .product-prices dt,
  .product-card:not(.is-placeholder):hover .product-prices dd,
  .product-card:not(.is-placeholder):hover .product-prices dd.is-emphasis,
  .product-card:not(.is-placeholder):hover .release-field-label,
  .product-card:not(.is-placeholder):hover .release-field-input,
  /* The published figures need naming here like every other text class on the
     card: this rule is an explicit list, not a descendant sweep, so anything
     left out of it stays dark grey on the dark hover ground and disappears.
     .batch-release-pick is deliberately NOT in the list — it keeps its own
     light fill, because setting a <select>'s colour to white paints the open
     option list white on white in Blink. */
  .product-card:not(.is-placeholder):hover .batch-release-title,
  .product-card:not(.is-placeholder):hover .batch-release-lot,
  .product-card:not(.is-placeholder):hover .batch-release-value,
  .product-card:not(.is-placeholder):hover .qty-value,
  .product-card:not(.is-placeholder):hover .doc-link,
  .product-card:not(.is-placeholder):hover .product-compare {
    color: #fff;
  }

  /* Blink and WebKit grey a disabled input through -webkit-text-fill-color and
     ignore `color`, so the count on an unsellable card needs both or it stays
     dark grey on the dark ground — see .qty-input:disabled. */
  .product-card:not(.is-placeholder):hover .qty-input,
  .product-card:not(.is-placeholder):hover .qty-input:disabled {
    color: #fff;
    -webkit-text-fill-color: #fff;
  }

  .product-card:not(.is-placeholder):hover .qty-line-total,
  /* "Not recorded" keeps its step back from a real figure on the dark ground
     too — white would state it as firmly as the result beside it. */
  .product-card:not(.is-placeholder):hover .batch-release-value.is-absent,
  .product-card:not(.is-placeholder):hover .release-field-input::placeholder {
    color: rgba(255,255,255,0.78);
  }

  /* The three hairlines that divide the card's blocks. */
  .product-card:not(.is-placeholder):hover .product-specs,
  .product-card:not(.is-placeholder):hover .product-prices,
  .product-card:not(.is-placeholder):hover .qty-stepper {
    border-color: rgba(255,255,255,0.22);
  }

  /* Chips and controls that fill themselves with a light surface. Each gets a
     translucent white instead, so it stays a distinct object on the dark ground
     without becoming a bright block on it. */
  .product-card:not(.is-placeholder):hover .product-class {
    background: rgba(255,255,255,0.14);
    border-color: rgba(255,255,255,0.34);
    color: #fff;
  }

  .product-card:not(.is-placeholder):hover .product-new {
    background: none;
    border-color: rgba(255,255,255,0.55);
    color: #fff;
  }

  .product-card:not(.is-placeholder):hover .qty-stepper { background: rgba(255,255,255,0.08); }

  .product-card:not(.is-placeholder):hover .qty-btn {
    background: rgba(255,255,255,0.16);
    color: #fff;
  }

  /* .qty-add keeps the accent fill — it is the card's primary action and the
     one thing that should not recede when everything around it darkens. */
  .product-card:not(.is-placeholder):hover .qty-add { background: var(--accent); }

  /* Both typed fields fill with a light surface on their own hover and focus
     (--bg-soft and --card), which under white text is white on near-white. The
     translucent replacements keep the "you can type here" feedback and keep the
     figure readable. The focus outline is untouched: it is drawn in --accent
     outside the box and reads on either ground. */
  .product-card:not(.is-placeholder):hover .release-field-input:hover,
  .product-card:not(.is-placeholder):hover .release-field-input:focus,
  .product-card:not(.is-placeholder):hover .qty-input:hover:not(:disabled),
  .product-card:not(.is-placeholder):hover .qty-input:focus {
    background: rgba(255,255,255,0.16);
  }

  /* Pending-data bars are filled with --bg-soft, which on this ground would be
     a row of bright blocks — louder than the values they stand in for. */
  .product-card:not(.is-placeholder):hover .ph-bar { background: rgba(255,255,255,0.28); }

  /* The one line an incomplete card exists to show. It keeps its own strip so
     it stays set apart from the figures above it.

     This is where the red has to be re-stated rather than inherited. The strip
     composites to #62686d under the pointer, and --danger measures 1.04:1 on
     that — not dim, gone. Nor can the red simply be lightened until it passes:
     the ground is mid-grey, so 4.5:1 needs a luminance around 0.79, which is
     nearly white whatever hue it carries. #ffe0da is the compromise that was
     actually measured — 4.55:1, so it clears AA, while still holding enough of
     a warm cast to read as the same red rather than as plain white. The border
     keeps more saturation than the text because it only has to be SEEN, not
     read: #ffb3a7 is 3.29:1, over the 3:1 that non-text contrast asks for. */

  /* RETIRED 2026-08-31 with the plate's pack shot. This lightened the border of
     a photograph on a hovered card so the dark ground did not swallow it. No
     .pack-image is a descendant of .product-card any more — the two live in the
     images view and the zoom layer, neither of which is inside a card — so the
     selector could never match again. Deleted rather than left: section 2 of
     tools/verify-clean.ps1 counts a rule nothing can use as dead CSS, and a
     hover rule for an element that has moved is exactly the drift it looks for. */
}
/* A listing missing one of REQUIRED_FIELDS. The hover lift goes, because the
   lift is this grid's way of saying "this one is actionable" and this one is not
   — the same reason .is-placeholder drops it.

   THE DARK HOVER GROUND ABOVE STILL APPLIES HERE, and the split is the point:
   the lift claims the card can be acted on, while the ground only says the
   pointer is on this one. An incomplete listing is still a card to be read, and
   in a grid of twelve, being able to see which one you are reading is worth
   more on the card whose text you are about to quote back to a supplier. A
   placeholder card takes neither, having nothing to read either.

   Nothing else about the card is dimmed, and that is a decision rather than an
   omission. It is a real product with a real gap, so every figure the price list
   DID supply has to stay readable; and .ph-bar is filled with --bg-soft, so
   tinting the card surface would have made the pending-data bars — the very
   things an incomplete card exists to show — invisible against it. The state is
   carried by the disabled stepper and the pending-data bars instead. It was
   also carried by .product-inactive-note until that note was retired on
   2026-09-05, so the words are gone and the gap now states itself. */
@media (hover: hover) {
.product-card.is-inactive:hover {
  box-shadow: none;
  transform: none;
}
}
/* A real card's missing generic name, as opposed to a placeholder card's. The
   paragraph is otherwise one to three lines of text, so the bar takes a line's
   worth of height and the card keeps its rhythm with the ones beside it. */
.product-generic .ph-bar {
  margin: 3px 0;
}

/* Two pulses on arrival, so the ring is noticed on a page that has just scrolled
   several thousand pixels. Animating the ring's own OFFSET rather than a
   coloured box-shadow is what keeps it right on all eleven pages: a shadow would
   have had to name the violet as a literal rgba(), and this card sits inside
   .company-page where companyStyle() overrides --accent. */
@keyframes product-found {
  from, to { outline-offset: 2px; }
  50%      { outline-offset: 9px; }
}

/* The one card the Search button pointed at — the opposite case to
   .nav-search-go above: this element IS inside the company wrapper, so the ring
   wears that page's ink. Verified on Galaxy: rgb(127,77,13).

   A ring rather than a tint or a scale, because the plate below is already
   tinted by therapeutic category and the mark inside it is the product's only
   picture — nothing may recolour or resize the card without changing what it
   says about the product. outline rather than border, because a border would
   add 2px to a card in a row of uniform-height cards and shift every neighbour.

   It doubles as the focus indicator: <App /> focuses this card as well as
   scrolling to it, and since the card is focusable for exactly as long as it is
   the found one, found and focused are one state — a separate :focus rule would
   draw the same ring twice. */
.product-card.is-found {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  box-shadow: var(--shadow);
  animation: product-found 0.9s ease-out 2;
  /* Joins .products and .company-strip in this rule, or the card lands under
     the fixed navbar. A card is often taller than the window (613px against
     550px measured), so no centring can keep its top clear instead. */
  scroll-margin-top: calc(var(--nav-h) + 20px);
}

/* The ring is the answer and stays put; only the pulse is motion. */
@media (prefers-reduced-motion: reduce) {
  .product-card.is-found { animation: none; }
}

/* -- Brand plate (stands in for the product photo) ------------------------- */
/* The brand name is the product's only picture, so this block is treated like
   pack artwork: the therapeutic-class tint fills it, and the mark is set in
   that product's display face. min-height rather than aspect-ratio keeps it
   only as tall as the mark needs — an aspect box wasted ~60px per card. */
.product-plate {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.4375rem;
  min-height: 5.5rem;
  padding: 0.9375rem 0.8125rem 0.6875rem;
  background: var(--tc-tint);
  border-bottom: 1px solid var(--tc-edge);
  text-align: center;
}

/* The mark itself. Every value here comes from the product's BRAND_MARKS entry
   via cardStyle(); --mk-scale normalises the very different x-heights of these
   faces so no card's mark reads as undersized.

   --bm-ink is the mark's OWN colour and the only thing on the card that carries
   it: five dosage-form and therapeutic-group colours set in brandInk(), falling
   back to the category ink for the listings that take none. The pill below the
   mark deliberately keeps --tc-ink, so the plate still says which therapeutic
   family the product is in even when the mark has been recoloured.

   Every one of the five clears AA against all fourteen CATEGORY_FAMILIES tints
   — computed, not eyeballed, worst case 5.00:1 (the pink on the psych tint
   #e5dff7, which is the darkest of the fourteen and sets the worst case for
   four of the five). Re-measure if either table changes; the mark is the
   largest text on the card and the last thing that should be hard to read. */
.product-brandmark {
  margin: 0;
  font-family: var(--mk-font);
  font-weight: var(--mk-weight);
  font-style: var(--mk-style);
  font-size: calc(1.32rem * var(--mk-scale));
  letter-spacing: var(--mk-tracking);
  text-transform: var(--mk-transform);
  line-height: 1.08;
  color: var(--bm-ink, var(--tc-ink));
  text-wrap: balance;
}

/* Therapeutic class, in the same ink as the mark. */
.product-class {
  padding: 0.125rem 0.5625rem;
  border: 1px solid var(--tc-edge);
  border-radius: 999px;
  background: rgba(255,255,255,0.6);
  color: var(--tc-ink);
  font-size: 0.6rem;
  font-weight: 700;
  letter-spacing: 0.9px;
  text-transform: uppercase;
  white-space: nowrap;
}

/* Discount chip, inline in the meta row beside the struck-through price. It
   used to be a corner badge on the plate, but that overlapped longer brand
   marks — and the mark is the product's photo, so nothing may cover it. */
.product-badge {
  padding: 0.125rem 0.375rem;
  border-radius: 6px;
  background: var(--danger);
  color: #fff;
  font-size: 0.64rem;
  font-weight: 700;
  letter-spacing: 0.2px;
  font-variant-numeric: tabular-nums;
}

/* "New" chip, for the sixteen Dawa lines the price list flags that way. Outlined
   in the accent rather than filled like the discount chip: a discount is a
   number you act on, this is only a label, and two solid chips side by side
   would compete. Accent, so it recolours per company page. */
.product-new {
  padding: 0.125rem 0.4375rem;
  border: 1px solid var(--accent);
  border-radius: 999px;
  color: var(--accent);
  background: var(--card);
  font-size: 0.62rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.6px;
}

/* -- Card body ------------------------------------------------------------- */

.product-info {
  display: flex;
  flex-direction: column;
  gap: 0.3125rem;
  flex: 1;
  padding: 0.6875rem 0.75rem 0.75rem;
}

.product-company {
  color: var(--accent-2);
  font-size: 0.66rem;
  font-weight: 700;
  line-height: 1.4;
  letter-spacing: 0.7px;
  text-transform: uppercase;
}

/* Generic (INN) name, strength and dosage form. Bounded so a long name can't
   stretch the card past its neighbours in the row. */
.product-generic {
  margin: 0;
  font-size: 0.78rem;
  line-height: 1.35;
  color: var(--text-dim);
}

/* Pack size, plus assay and dissolution where a price list supplies them. A
   <dl> because these are label/value pairs; the grid keeps the values in one
   column so they scan down the card. tabular-nums stops the figures jittering
   between cards. */
.product-specs {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.0625rem 0.5rem;
  margin: 2px 0 0;
  padding-top: 0.4375rem;
  border-top: 1px solid var(--border);
  font-size: 0.72rem;
  /* A pack code wraps to two lines on a narrow card — "10X1's(Mono Pack)" —
     and inherited `normal` was about 1.2 there. */
  line-height: 1.4;
}

.product-specs dt {
  color: var(--text-dim);
  font-weight: 600;
}

.product-specs dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

/* Pack sizes are codes — "10X1's(Mono Pack)", "Vial (Without Water)" — so they
   read better slightly emphasised and are worth not truncating. */
.product-pack { font-weight: 600; }

/* Stands in for a pack size the price list does not carry. Sized to the line it
   replaces so the row keeps its height, and vertically centred because a bar has
   no baseline of its own to sit on. */
.ph-pack {
  width: 58px;
  height: 9px;
  margin: 4px 0;
}

/* -- Editable release figures ---------------------------------------------- */
/* Assay and dissolution, typed per product. Same two-column rhythm as
   .product-specs above, so an editable row lines up with the read-only one. */
.release-fields {
  display: grid;
  gap: 3px;
}

.release-field {
  display: grid;
  grid-template-columns: max-content 1fr;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.72rem;
}

.release-field-label {
  color: var(--text-dim);
  font-weight: 600;
}

/* Flat until touched: twenty-four bordered inputs down a product grid read as a
   data-entry form rather than as a catalogue. The dotted underline is the only
   resting affordance — enough to say "editable", quiet enough to ignore. */
.release-field-input {
  width: 100%;
  min-width: 0;
  padding: 2px 4px;
  border: 1px solid transparent;
  border-bottom: 1px dashed var(--border);
  border-radius: 4px;
  background: none;
  color: var(--text);
  font: inherit;
  font-variant-numeric: tabular-nums;
}

@media (hover: hover) {
.release-field-input:hover {
  border-bottom-color: var(--accent);
  background: var(--bg-soft);
}
}

/* A real focus ring, not just the hover treatment: this is the only way a
   keyboard user knows which of the two fields they are in.

   An outline rather than the box-shadow ring the navbar search uses, because
   this input sits inside .company-page, where companyStyle() overrides --accent
   per company. outline: var(--accent) recolours with it; the navbar's
   hardcoded rgba() would stay purple on all ten pages. */
.release-field-input:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: transparent;
  border-bottom-style: solid;
  background: var(--card);
}

/* Dimmer than the value, so an empty field reads as "nothing recorded" rather
   than as a figure. */
.release-field-input::placeholder {
  color: var(--text-dim);
  opacity: 0.62;
  font-style: italic;
}

/* The placeholder card's version — a span, so it has no input affordances at
   all and nothing inside the aria-hidden grid can take focus. */
.release-field-input.is-placeholder {
  border-bottom-color: var(--border);
  color: var(--text-dim);
}

/* -- Published batch release figures --------------------------------------- */
/* What the shop has actually recorded against a lot, as opposed to the editable
   notes above. Read-only, so nothing here wears an input's affordances — no
   dashed underline, no hover fill, no focus ring except on the batch picker,
   which is a real control. The two rows reuse .release-field, so a card that
   publishes figures is the same height as one that does not and the labels line
   up down a grid. */
.batch-release {
  display: grid;
  gap: 3px;
}

.batch-release-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  font-size: 0.72rem;
}

.batch-release-title {
  color: var(--text-dim);
  font-weight: 600;
}

/* The lot the figures belong to. Never wrapped: a batch number broken across
   two lines reads as two batch numbers. */
.batch-release-lot {
  color: var(--text);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* The picker, shown only where a listing has more than one lot. It is bordered
   and filled, unlike everything else in this block, because it IS operable and
   the two release rows beside it are not — the affordance is the only thing
   saying which of the three things on this card can be changed. */
.batch-release-pick {
  max-width: 55%;
  padding: 1px 4px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--card);
  color: var(--text);
  font: inherit;
  font-variant-numeric: tabular-nums;
}

.batch-release-pick:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.batch-release-value {
  font-size: 0.72rem;
  font-variant-numeric: tabular-nums;
}

/* A lot with no figure for this test — an injection has no dissolution result
   at all, so this is a normal state and not a gap. Dimmer and italic, the same
   treatment an empty editable box gets, because the absence of a result must
   not read as one. */
.batch-release-value.is-absent {
  color: var(--text-dim);
  font-style: italic;
}

/* Trade and wholesale, one above the other. Same two-column grid as the specs
   above so the two blocks share a label column and the figures line up, but
   right-aligned values: prices are compared down a column, not read across. */
.product-prices {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.125rem 0.5rem;
  margin: 0;
  padding-top: 0.4375rem;
  border-top: 1px solid var(--border);
  font-size: 0.74rem;
}

.product-prices dt {
  color: var(--text-dim);
  font-weight: 600;
}

.product-prices dd {
  margin: 0;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* The figure the storefront leads with, set by EMPHASISED_PRICE in app.jsx —
   wholesale. NOT necessarily the figure the cart charges (CART_PRICE_BASIS, currently
   trade): the two are separate settings, so this must not be read as "the price
   you pay". Weight and size carry the emphasis, not colour alone. */
.product-prices dd.is-emphasis {
  font-size: 1rem;
  font-weight: 700;
  color: var(--text);
}

/* States the WHOLESALE_BREAK_QTY rule on the card. Rendered only where the two
   prices actually differ, so it never promises a discount a price list does not
   offer. Set in accent-2 rather than --text-dim: it is a term of sale, not a
   caption, and --text-dim at this size on the card ground is the faintest ink
   in the component. */
/* `.price-break-note` lived here — the card's "Wholesale from 20 packs" line.
   Removed with its markup on 2026-08-01; the rule went too rather than being
   left orphaned, which is what verify-clean.ps1's dead-CSS direction is for.
   WHOLESALE_BREAK_QTY still applies: see the comment on it in app.jsx. */

/* -- An incomplete listing -------------------------------------------------- */
/* Why a card cannot be ordered: it is missing one of REQUIRED_FIELDS. Sits
   directly above the stepper it explains.

   --danger, on the shop's instruction (2026-08-16). That reverses what this
   rule used to say, and the old reasoning is kept here because it still governs
   the WORDING: red was refused while the line read "Not orderable — no trade
   price or wholesale price", on the grounds that nothing was broken and nothing
   the customer did went wrong, so red would have reported a fault in the
   storefront that did not exist. The line now reads "Uploading Stocks..." — a
   live status the shop wants noticed, not a fault it wants excused — so red is
   doing a different job and the objection no longer applies. If the wording
   ever goes back to naming the missing fields, this colour should go back too.

   Measured, not eyeballed: --danger on the --bg-soft strip is 4.68:1, which
   clears AA for body text at this size (0.68rem is ~11px, so the 4.5:1
   threshold applies, not the large-text one). It does give up the 12.74:1 that
   --text had, which makes this the least legible line on the card rather than
   the most — worth knowing, since a purchasing officer still has to read it.
   The border takes the same red so the strip does not read half-changed.

   The dark hover ground needs a different colour entirely: plain --danger
   measures 1.04:1 there and would disappear. See the hover block above. */
/* .product-inactive-note WAS HERE and was deleted on 2026-09-05 with the
   "Uploading Stocks..." line it styled — the only thing that ever carried it.
   Its rule is gone rather than left behind, because a class named by no string
   literal in app.jsx is what verify-clean.ps1 section 2 reports as dead CSS,
   and a stylesheet that keeps rules for markup that no longer exists is how
   that audit gets trained into noise.

   WHAT IT LOOKED LIKE, for anyone reinstating it: 3px --danger left rule, 8%
   --bg-soft ground, --danger ink at 0.68rem/700 with 0.3px tracking, square
   left corners. The same "this is why you cannot" language .admin-confirm-warn
   still uses, which is now the last rule on the site speaking it. */

/* Leaflet and product-image links. Two elements wear this class, and which one
   a card gets depends on whether a file exists: the Leaflet link is an <a> to
   leaflets.html#<id> for the 110 rows whose leaflet was supplied, and a <button>
   opening the ProductDocs modal for every other row and for Product images,
   which has no file behind it at all.

   The rules below are therefore written to sit on both. `padding: 0` and the
   three `none`s are what strip a button back to looking like text; an anchor
   needs none of them but is unharmed by them, and keeping one block rather than
   `a.doc-link` / `button.doc-link` is what stops the two drifting apart. */
.product-docs {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 12px;
}

.doc-link {
  padding: 0;
  border: none;
  background: none;
  color: var(--accent-2);
  font-size: 0.74rem;
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 2px;
}

@media (hover: hover) {
.doc-link:hover { color: var(--accent); }
}

/* -- Leaflet text ---------------------------------------------------------- */
/* The leaflet is TEXT in this page, never a file: there is no PDF linked,
   embedded or served anywhere in the app, so there is nothing to save and no
   viewer chrome offering to. What remains to be discouraged is printing, which
   the block at the end of this section handles. */

/* Sits above the body, so it frames what follows rather than footnoting it. */
.leaflet-disclaimer {
  margin: 0 0 14px;
  padding: 0.625rem 0.8125rem;
  border: 1px solid var(--border);
  border-left: 4px solid var(--star);
  border-radius: 8px;
  background: var(--bg-soft);
  color: var(--text);
  font-size: 0.78rem;
  line-height: 1.5;
}

/* Between the disclaimer and the body, and saying a different thing from both.
   The disclaimer frames every leaflet on the site; this one appears only where
   the supplied document declares its own text a draft not cleared for release.
   So it is scaffolding and is dressed as scaffolding -- dashed --danger over a
   5% tint -- on the principle the reset screen's issued-code panel was built on
   before that panel was deleted: the one unreliable thing on screen must not be
   the most reassuring.

   Body in --text, not --danger, following what .product-inactive-note did
   before it was retired. Measured on
   the tint this sits on, red is 4.97:1 and the ink is 13.52:1, and a reader who
   has to be warned by a sentence has to be able to read the sentence. The
   border carries the signal, the words carry the meaning -- and the sentence
   opens with the word DRAFT in the document's own voice, so nothing here has to
   label it. */
.leaflet-draft {
  margin: 0 0 14px;
  padding: 0.625rem 0.8125rem;
  border: 1px dashed var(--danger);
  border-radius: 8px;
  background: rgba(192,57,43,0.05);
  color: var(--text);
  font-size: 0.78rem;
  font-weight: 700;
  line-height: 1.5;
}

/* The scroll lives here rather than on the modal, so the tabs and the product's
   own heading stay put while a long leaflet moves under them. */
.leaflet-body {
  max-height: 52vh;
  overflow-y: auto;
  padding-right: 8px;
}

.leaflet-section + .leaflet-section { margin-top: 15px; }

.leaflet-section h3 {
  margin: 0 0 4px;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--accent-2);
}

.leaflet-section p {
  margin: 0;
  font-size: 0.82rem;
  line-height: 1.6;
  color: var(--text);
}

/* Printing. The key handler in <ProductDocs /> catches Ctrl/Cmd+P; it cannot
   see the browser's own File > Print, so the leaflet is removed from the
   printed page here as well and says why. Both are deterrents rather than
   enforcement -- the pack insert is the document to act on, and a printout that
   looked like one would be the thing worth avoiding. */
@media print {
  /* .leaflet-draft goes with the body it describes. Left standing it would warn
     a printed page about wording that is no longer on it, and the disclaimer's
     ::after below already says why the page is empty. */
  .leaflet-body,
  .leaflet-draft,
  .leaflet-sections { display: none !important; }

  .leaflet-disclaimer::after {
    display: block;
    margin-top: 8px;
    font-weight: 700;
    content: "This leaflet is not available in printed form. Refer to the direction slip inside the medicine pack.";
  }
}

/* Rating and any discount on one line. margin-top:auto pins this and the
   stepper below it to the bottom, so cards with longer generic names still line
   up along the row. The row is not rendered at all when a product has neither a
   rating nor a discount, so the stepper inherits the pin in that case — hence
   margin-top:auto on .qty-stepper too. */
.product-meta {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 3px 8px;
  margin-top: auto;
  padding-top: 5px;
}

.product-rating {
  display: flex;
  align-items: center;
  gap: 0.1875rem;
  font-size: 0.75rem;
  color: var(--text-dim);
}

.product-star { color: var(--star); }

/* Pushed right so the struck price sits opposite the rating. */
.product-compare {
  margin-left: auto;
  color: var(--text-dim);
  font-size: 0.78rem;
  font-variant-numeric: tabular-nums;
}

/* -- Quantity stepper ------------------------------------------------------ */
/* Replaces the old single Add button, so − and + are on every card rather than
   appearing only once a line exists. Three columns with the count in the middle
   on a 1fr, which keeps the two buttons the same size whether the count is 1 or
   12 — a stepper whose buttons move as you click it is hard to click twice.
   The 1fr is also what gives the count field room to be typed into: it holds
   four digits and the line total under them without the buttons moving. */
.qty-stepper {
  display: grid;
  grid-template-columns: 2.375rem 1fr 2.375rem;
  align-items: stretch;
  margin-top: auto;
  /* Room for the line total whether or not one is showing. Without it the
     stepper grows ~15px the moment a card gets a quantity, which pushes
     everything below the grid down and leaves the steppers in a row sitting at
     different heights from each other. */
  min-height: 3.125rem;
  border: 1px solid var(--border);
  /* Tighter than --radius (14px), which is the card's own corner — a control
     inside it wants a smaller one or the two radii fight. */
  border-radius: 0.5625rem;
  background: var(--card);
  overflow: hidden;
}

/* THE MINUS WAS READING AS EMPTY SPACE, and this is a colour change only —
   nothing here touches the grid, the 2.375rem columns, the min-height or the
   radius, so the stepper's shape is exactly what it was.

   THE MEASUREMENT THAT PROMPTED IT: .qty-stepper paints --card (#fffdf7) and
   this button painted --bg-soft (#edefe4) on top of it. That is 1.14:1 — a fill
   the eye cannot separate from its own container, so the left third of the
   control looked like a gap with a glyph floating in it while the right third,
   carrying --accent, plainly looked like a button. The two halves of one control
   disagreed about whether they were pressable.

   SAGE AT 30% OVER THE CARD, resolving to about #d4e1ce: 1.31:1 against the
   container. That is a small number and it is the honest one — a light fill on a
   near-white card cannot separate on LUMINANCE, which is equally true of
   .btn-sunset (1.58:1) and of every tinted surface in this file. What carries it
   is hue: a green-grey against warm cream reads immediately where a grey-green
   against off-white did not. --text on the new fill is 11.0:1, better than the
   4.5:1 floor by a wide margin and barely moved from the 12.7:1 it had.

   THE BRAND'S OWN GREEN, not a new colour. --surface-band is the navbar and
   footer band and "Go back"'s fill, so the quiet half of the stepper now belongs
   to the same family as the rest of the furniture instead of being the one grey
   on the card. */
.qty-btn {
  border: none;
  background: rgba(111, 159, 111, 0.30);
  color: var(--text);
  font-size: 1.05rem;
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}

@media (hover: hover) {
.qty-btn:hover:not(:disabled) {
  background: var(--accent);
  color: #fff;
}
}

/* Dimmed rather than hidden: the control's shape should not change between a
   card with nothing in the cart and one with something, or the grid twitches.

   0.55 AND NOT 0.35, raised 2026-09-05 with the fill above and for the same
   complaint. At 0.35 both buttons washed most of the way out to the card behind
   them — the − on a card with an empty cart, and BOTH on an unsellable card —
   so a control that is merely unavailable looked like a rendering fault. 0.55
   still reads as clearly quieter than the live state while leaving the glyph and
   the fill visible.

   IT IS STILL AN INACTIVE CONTROL, which 1.4.3 exempts from the contrast floor,
   and the dimming is deliberately the only signal that changes: the shape, the
   position and the glyph are identical either way, which is the whole point of
   the note above. */
.qty-btn:disabled {
  opacity: 0.55;
  cursor: default;
}

/* The + is the primary action of the card, so it carries the accent from rest
   rather than only on hover. --accent is overridden per company page, so this
   recolours with the rest of the landing page. */
.qty-add {
  background: var(--accent);
  color: #fff;
}

@media (hover: hover) {
.qty-add:hover:not(:disabled) { filter: brightness(1.08); }
}

/* The middle column: the count field, and under it the line total. Still sets
   the type, since .qty-input inherits its font rather than restating it.

   4px of vertical padding, down from 7px, and the 3px buys the input its
   height. An <input> box is taller than the bare text node it replaced — 21.3px
   against ~18px, measured — and at the old padding the stack of field + gap +
   line total came to 51.7px, breaking through the 50px min-height that exists
   precisely so a card does not grow when it gets a quantity. Field and total
   now measure 48.4px together, so min-height governs both states again and a
   row of cards stays level whatever is in the cart. */
.qty-value {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.0625rem;
  padding: 0.25rem;
  font-size: 0.92rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* The count is typed, not just clicked to: twenty packs is four clicks past the
   wholesale break and two hundred is not reachable by clicking at all.

   Flat until touched, exactly like the assay and dissolution inputs above it —
   a bordered box here would read as a form control sitting inside a control
   that is already a bordered box, and twelve of those down a grid turn a
   catalogue into a data-entry screen. The dashed underline is the whole resting
   affordance, and it is the same one those two fields use, so "dashed rule
   means you can type here" holds everywhere on the card.

   Capped rather than filling the 1fr column: the column is 167px wide on a
   desktop card and "9999" measures 34px, so a full-width rule would run a
   dashed line five times the width of the figure above it and read as a divider
   between the count and its line total rather than as a field. 74px is a little
   over twice the four-digit width — clearly a box, still an easy target. */
.qty-input {
  width: 100%;
  min-width: 0;
  max-width: 74px;
  padding: 0 2px;
  border: 1px solid transparent;
  border-bottom: 1px dashed var(--border);
  border-radius: 4px;
  background: none;
  color: var(--text);
  font: inherit;
  /* Explicit, because an input's default `normal` resolves through the font's
     own metrics to 20px here — 2px more than the text node it replaced, which
     is most of what pushed the stepper past its min-height. */
  line-height: 1.5;
  text-align: center;
}

@media (hover: hover) {
.qty-input:hover:not(:disabled) {
  border-bottom-color: var(--accent);
  background: var(--bg-soft);
}
}

/* Same outline treatment as .release-field-input, and for the same reason: it
   is drawn in --accent, which companyStyle() overrides per landing page, so the
   ring recolours with the page instead of staying purple on all ten. */
.qty-input:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: transparent;
  border-bottom-style: solid;
  background: var(--card);
}

/* Inert on a placeholder card and on a product with no trade price. Stripped
   back to plain text: the dashed rule is an invitation to type and there is
   nothing to type into, and the count must look exactly as it did when it was
   a span or the placeholder grid stops matching the real one.
   -webkit-text-fill-color as well as color because Blink and WebKit grey
   disabled input text through that property and ignore `color` alone. */
.qty-input:disabled {
  border-bottom-color: transparent;
  color: var(--text);
  -webkit-text-fill-color: var(--text);
  opacity: 1;
}

/* Line total, shown only once there is a quantity. Small and dim so it reads as
   a consequence of the count above it rather than as a third price. */
.qty-line-total {
  font-size: 0.66rem;
  font-weight: 600;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

.empty-state {
  text-align: center;
  padding: 52px 20px;
  background: var(--card);
  border: 1px dashed var(--border);
  border-radius: var(--radius);
}

.empty-state h2 {
  margin: 0 0 10px;
  font-size: 1.2rem;
}

/* Capped and re-centred: the "catalogue pending" copy is a paragraph, not a
   one-liner, and a full-container measure is unreadable at desktop widths. */
.empty-state p {
  margin: 0 auto 16px;
  max-width: 62ch;
  color: var(--text-dim);
}

/* -- Narrow-card breaks ---------------------------------------------------- */
/* Keyed to the card, not the viewport (see useCardWidthClass, which sets these
   classes from the card's own measured width).
   235px is measured, not picked: "Dissolution" is 59px and the longest value,
   "86% at 45 min (buffer stage)", is 150px, so a side-by-side row needs ~217px
   of content — about a 241px card. Below that the value column squeezes and
   that string breaks mid-parenthetical.

   Stacking gives the value the card's full width, where it fits on one line, so
   the spec block costs the same two lines it did before rather than three. The
   trims below claw back the extra line the short values now take. */
.product-card.is-narrow .product-specs {
  grid-template-columns: 1fr;
  gap: 0 0;
}

.product-card.is-narrow .product-specs dd {
  margin-bottom: 3px;
}

.product-card.is-narrow .product-plate {
  min-height: 76px;
  gap: 5px;
  padding: 12px 9px 9px;
}

.product-card.is-narrow .product-brandmark { font-size: calc(1.16rem * var(--mk-scale)); }

.product-card.is-narrow .product-info { gap: 0.25rem; padding: 0.625rem 0.625rem 0.6875rem; }

.product-card.is-narrow .product-generic { font-size: 0.75rem; }

/* Let the meta line flow from the left once wrapped — pinning the struck
   price right leaves it stranded on its own row. */
.product-card.is-narrow .product-compare { margin-left: 0; }

/* The price rows stay side-by-side even here: "Wholesale" plus "KSh 1,865" is
   about 120px, well inside a 235px card's content width. Only the emphasised
   figure comes down, to match the smaller type around it. */
.product-card.is-narrow .product-prices { font-size: 0.71rem; }
.product-card.is-narrow .product-prices dd.is-emphasis { font-size: 0.94rem; }

/* Narrower buttons, so the count and its line total keep room between them. */
.product-card.is-narrow .qty-stepper { grid-template-columns: 2rem 1fr 2rem; }

/* "Dissolution" is 59px of the label column; below this width the input left
   over is too narrow to read a value like "94% at 30 min" in, so stack the
   field the same way .product-specs stacks. */
.product-card.is-narrow .release-field {
  grid-template-columns: 1fr;
  gap: 0;
}

/* Very narrow cards only occur if a future breakpoint packs them tighter; the
   class pill is the first thing to overflow, so let it wrap rather than push
   the plate wider than its column. */
.product-card.is-very-narrow .product-class { white-space: normal; }
.product-card.is-very-narrow .product-brandmark { font-size: calc(1.05rem * var(--mk-scale)); }

/* ==== END: Products — grid & card ========================================== */


/* ==== BEGIN: Product card placeholders ===================================== */
/* Empty cards for a landing page whose catalogue has not been uploaded. They
   reuse every .product-* class above, so the geometry and both width
   breakpoints come for free — only the value slots differ. */

.placeholder-head { margin: 0 0 20px; }

.placeholder-head h2 {
  margin: 0 0 6px;
  font-size: 1.15rem;
}

.placeholder-head p {
  margin: 0;
  max-width: 68ch;
  color: var(--text-dim);
  font-size: 0.86rem;
  line-height: 1.55;
}

/* Dashed, matching the other placeholders in this app (empty-state panels, the
   docs-modal image tiles). The plate borrows the company's own tint via --co-*,
   so a placeholder still reads as that company's page rather than as grey
   chrome. The fallbacks only matter if one is ever rendered outside a
   .company-page, where --co-* is not defined. */
.product-card.is-placeholder {
  --tc-tint: var(--co-tint, var(--bg-soft));
  --tc-edge: var(--co-edge, var(--border));
  --tc-ink: var(--co-ink, var(--text-dim));
  border-style: dashed;
}

/* No lift on hover — there is nothing to open. */
@media (hover: hover) {
.product-card.is-placeholder:hover {
  box-shadow: none;
  transform: none;
}
}

.doc-link.is-placeholder {
  color: var(--text-dim);
  text-decoration-style: dashed;
  cursor: default;
}

.ph-bar {
  display: block;
  border-radius: 5px;
  background: var(--bg-soft);
}

/* A --bg-soft bar vanishes against the tinted plate; translucent white reads
   there the same way the category pill does. */
.product-plate .ph-bar {
  background: rgba(255,255,255,0.6);
  border: 1px solid var(--tc-edge);
}

/* Heights match the type they stand in for: the mark is 1.32rem at line-height
   1.08, the generic 0.78rem at 1.35. The pack-size, price and quantity rows are
   not bars — they render their real labels with an em dash, so they need no
   stand-in. */
.ph-company       { width: 52%;   height: 8px; }
.ph-mark          { width: 74%;   height: 22px; }
.ph-pill          { width: 86px;  height: 17px; border-radius: 999px; }
.ph-generic       { width: 100%;  height: 10px; }
.ph-generic.short { width: 58%; }

/* The same measured 235px card break the real cards use, applied to the bars. */
.product-card.is-narrow .ph-mark  { height: 1.1875rem; }
.product-card.is-narrow .ph-pill  { width: 4.625rem; }

/* ==== END: Product card placeholders ======================================= */


/* ==== BEGIN: Modal ========================================================= */
/* Hosts both the cart and the auth card. z-index clears the fixed navbar. */

.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  overflow-y: auto;
  background: rgba(28,46,32,0.45);
  backdrop-filter: blur(2px);
}

.modal-card {
  position: relative;
  width: 100%;
  max-width: 440px;
  margin: auto;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 24px 60px rgba(28,46,32,0.28);
}

/* The wide card, for the legal documents and nothing else so far. 440px is a
   cart and a sign-in form; a contract read at that width runs to some 1,400
   lines of about six words, and Schedule 1's entitlement matrix is seven
   columns. 820px puts a clause at roughly 90 characters a line — long for
   prose, right for a reference document nobody reads end to end — and gives
   the widest table room to hold its ticks without the horizontal scroll it
   still keeps as a fallback. */
.modal-card-wide {
  max-width: 820px;
}

/* The tall card: capped to the screen, with the scrolling done INSIDE it.
   Opt-in via <Modal tall>, and so far the account panel is the only caller.

   WHAT IT CHANGES. Every other modal is a block read top to bottom, and the
   overlay's own `overflow-y: auto` is the right scroller for those — the whole
   dialog moves and the close button rides up with it. An order history is not
   that: it is a short fixed head and a list of unknown length, and letting the
   card grow to whatever the list wants means a 40-order history renders a card
   several screens tall whose close button is somewhere above the fold.

   TWO DECLARATIONS, NOT ONE. 90vh is the figure asked for and is what any
   browser that has heard of `min()` resolves to on a normal screen. The reason
   it is capped again at 100% is the overlay's own padding: the card is a flex
   item of a padded `inset: 0` box, so 100% here is the viewport MINUS that
   padding, and below a 400px-tall viewport 90vh is the larger of the two and
   would push the card into the gutter. A browser without `min()` keeps the
   plain 90vh above and is no worse off than it was.

   Percentages also sidestep the vh trap on a phone, where `100vh` is the
   URL-bar-hidden height and a 90vh card can stand taller than the screen
   actually showing it. dvh would say this outright; it needs Safari 15.4, which
   is above this file's floor. */
.modal-card-tall {
  max-height: 90vh;
  max-height: min(90vh, 100%);
  /* The column the caller's own flex children hang off. overflow: hidden is
     what stops a child that has not opted into scrolling from bursting the cap
     — the card clips, and the child that DID opt in scrolls. */
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.modal-close {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 1;
  width: 1.875rem;
  height: 1.875rem;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--bg-soft);
  color: var(--text);
  font-size: 0.85rem;
  display: flex;
  align-items: center;
  justify-content: center;
}

@media (hover: hover) {
.modal-close:hover { border-color: var(--accent); }
}

/* Modal receives focus on open (see the Modal component) purely so the dialog
   is reachable and its label announced — a ring around the whole card would
   just be noise, and every control inside keeps its own focus style. */
.modal-card:focus { outline: none; }

/* The auth card supplies its own surface on the page; inside the modal the
   modal-card is already that surface, so flatten it. The extra top padding
   drops the heading clear of the absolutely-positioned close button. */
.modal-card .auth-card {
  background: none;
  border: none;
  box-shadow: none;
  padding-top: 46px;
}

/* ==== END: Modal =========================================================== */


/* ==== BEGIN: Cart panel ==================================================== */

.cart-panel { padding: 24px 22px 26px; }

.cart-panel h2 {
  margin: 0 0 18px;
  font-size: 1.02rem;
}

/* The panel's stacked actions: "Pay on delivery" then "Go back" on the
   confirmation, "Go back" alone on the cart.

   A FLEX COLUMN WITH A GAP rather than margins on the buttons, so the spacing is
   stated once and does not depend on which children are rendered — the
   confirmation shows two buttons with a refusal that may or may not sit between
   them, the cart shows one, and none of those cases needs a :first-child
   exception.

   THE GAP IS WIDER THAN .pay-block's 12px, deliberately. Those buttons are
   consecutive steps in one flow; these two are a commitment and a way out, and a
   fat-thumb miss between them costs either an unintended pay-on-delivery or the
   loss of the payment screen the customer was aiming at. */
.cart-actions {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-top: 18px;
}

/* The refusal is a flex item, so the gap above already spaces it and its own top
   margin would double that into a hole between the two buttons. */
.cart-actions .pay-error { margin: 0; }

/* SMALLER TYPE ACROSS THE CHECKOUT, scoped to this panel and NOT applied to
   .btn globally — the navbar, hero and product cards share that class and
   nothing was asked of them.

   0.84rem is 13.4px, which is above the 12.8px the status-pill ratios in :root
   were measured at, so every figure quoted there still holds at this size.
   More generally: contrast is a property of two COLOURS and not of type size, so
   shrinking text cannot lower a ratio. What it CAN do is change which threshold
   applies — and every value in this panel was already measured against the
   stricter 4.5:1 normal-text bar rather than the 3:1 large-text one, so there is
   nothing here to re-measure. .pay-name is left alone at 0.85rem for that
   reason: its 4.98:1 was measured at that size and quoted in the note below. */
.cart-panel .btn { font-size: 0.84rem; }

.cart-empty {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.9rem;
}

.cart-lines {
  list-style: none;
  margin: 0 0 18px;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.cart-line {
  display: grid;
  grid-template-columns: 44px 1fr auto;
  gap: 12px;
  align-items: start;
}

/* Carries the product's therapeutic-class tint, so a cart of mixed classes is
   readable at a glance the same way the grid is. */
.cart-line-thumb {
  width: 44px;
  height: 44px;
  border-radius: 8px;
  background: var(--tc-tint);
  border: 1px solid var(--tc-edge);
}

.cart-line-company {
  color: var(--accent-2);
  font-size: 0.66rem;
  font-weight: 700;
  letter-spacing: 0.6px;
  text-transform: uppercase;
}

.cart-line-name {
  font-size: 0.88rem;
  line-height: 1.35;
  margin-top: 2px;
}

/* The line's money column: the total, and under it the basis note that only
   appears once the wholesale break applies. Right-aligned so the totals form a
   column the eye can add up, which a left-aligned note underneath would break. */
.cart-line-money {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 2px;
  min-width: 0;
}

.cart-line-price {
  font-size: 0.9rem;
  font-weight: 700;
  white-space: nowrap;
}

/* Deliberately NOT white-space: nowrap. `.cart-line` is a
   `44px 1fr auto` grid, so this note's min-content width sets the third
   column — and unwrapped, "wholesale · KSh 210 ea" measures 128px, which at a
   320px viewport pushed the line to 334px and out of a 296px modal. Allowed to
   wrap, its min-content is one word and the column collapses to fit. */
.cart-line-basis {
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.3px;
  text-transform: uppercase;
  color: var(--accent-2);
  text-align: right;
  text-wrap: balance;
}

.cart-qty {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 8px;
}

.cart-qty button {
  width: 1.625rem;
  height: 1.625rem;
  border-radius: 7px;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-size: 0.9rem;
  display: flex;
  align-items: center;
  justify-content: center;
}

@media (hover: hover) {
.cart-qty button:hover { border-color: var(--accent); }
}

/* Wrapper for the count field, matching the card skin's .qty-value so both
   variants render the same tree. It carries no line total — the cart line has
   its own money column two elements away. */
.cart-qty-value {
  display: flex;
  align-items: center;
}

/* A real bordered box here, where the card's is flat. The difference is
   deliberate: a cart line is a place you go to change quantities, so the field
   should look like a field and match the two 26px buttons it sits between,
   whereas twelve bordered boxes down a product grid would read as a form. */
.cart-qty-input {
  width: 2.875rem;
  height: 1.625rem;
  padding: 0 0.25rem;
  border: 1px solid var(--border);
  border-radius: 7px;
  background: var(--card);
  color: var(--text);
  font: inherit;
  font-size: 0.88rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  text-align: center;
}

@media (hover: hover) {
.cart-qty-input:hover { border-color: var(--accent); }
}

.cart-qty-input:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: transparent;
}

.cart-total {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  padding-top: 0.875rem;
  border-top: 1px solid var(--border);
  font-size: 0.84rem;
}

/* Still the largest thing in the panel, because the total is what the customer
   came to read — but 0.98rem rather than the 1.15rem it was, so it leads the
   hierarchy instead of shouting over the rest of the checkout. 15.7px, well
   clear of the 12.8px the measured ratios assume. */
.cart-total strong { font-size: 0.98rem; }

.cart-shipping {
  margin: 6px 0 16px;
  color: var(--text-dim);
  font-size: 0.8rem;
}

/* Why Checkout is disabled, stated in the layout rather than a tooltip. */
.cart-note {
  margin: 10px 0 0;
  color: var(--text-dim);
  font-size: 0.75rem;
  text-align: center;
}

/* Cart lines carry the generic name under the brand — two brands can be the
   same medicine, so the brand alone is ambiguous. */
.cart-line-generic {
  margin-top: 1px;
  color: var(--text-dim);
  font-size: 0.76rem;
  line-height: 1.35;
}

/* And the pack size under that, because brand plus generic is still ambiguous:
   nine of Square's listings are the same brand and strength at a different pack
   size and a different price. */
.cart-line-pack {
  margin-top: 2px;
  color: var(--text-dim);
  font-size: 0.72rem;
  font-weight: 600;
}

/* -- The payment step on the confirmation ----------------------------------- */

/* <PaymentChoice /> — the provider picker and both flows' status copy. Sits
   between the pending note and Done inside .cart-panel, so it inherits that
   panel's padding and needs none of its own; the rule below only separates it
   from the note above with a hairline, the way a new section of a form does.

   THE PROVIDER MARKS ARE TEXT IN BRAND COLOUR BESIDE A GENERIC GLYPH. Neither
   glyph is the provider's logo — see the comment above ICON_SHAPES.mpesa in
   app.jsx for that decision and why it follows facebook/twitter's precedent.

   NEITHER BRAND'S OWN VALUE COULD BE THE INK, and that is measured rather than
   a taste call. Safaricom's bright green (#4CB748) as text measures 2.53:1
   against --card — it fails AA's 4.5:1 for text and also the 3:1 floor a
   non-text indicator needs, so it would not even have worked on the glyph.
   Airtel's red is the same story a shade less badly. Both are deepened until
   they clear 4.5:1 against BOTH pill grounds, because .choice is --bg-soft
   unchecked and --card checked and the dimmer of the two governs:

     --pay-mpesa   #1F6E2C   5.43:1 on --bg-soft, 6.21:1 on --card
     --pay-airtel  #C81A21   4.98:1 on --bg-soft, 5.69:1 on --card

   The wordmark is 0.85rem and bold, which is NOT WCAG "large" (that needs
   18.66px bold), so 4.5:1 is the bar and not 3:1. If a literal brand value is
   ever wanted it has to become a FILLED PLATE with dark ink instead —
   --brand-ink on #4CB748 measures 7.25:1 — which is a different design, not a
   colour swap. Re-measure if either of these moves.

   --pay-pesa AND --pay-pal STOOD HERE UNTIL 2026-09-03, splitting the Pesapal
   wordmark blue/red. Pesapal was replaced by the direct Airtel push, so both
   are gone rather than left as dead tokens — verify-clean.ps1 audits class
   drift, but nothing audits an orphaned custom property. */
:root {
  --pay-mpesa: #1F6E2C;
  --pay-airtel: #C81A21;
}

/* ==== BEGIN: The registration fee screen =================================== */
/* THE ONE AUTH SCREEN THAT ASKS FOR MONEY, and it is scoped so the richer
   treatment here does not follow .auth-card into sign-in and sign-up. Those two
   must keep the plainer form styling; a payment screen that looked identical to
   a login is a payment screen people click through without reading. */

/* STANDALONE — the fee screen rendered on its own page rather than in a modal.
   A WARM LIFT rather than a flat fill: the gradient is barely there (two steps
   of the same cream), enough to make the panel read as its own surface without
   turning it into a coloured box behind black text. */
.registration-fee {
  background: linear-gradient(180deg, var(--card) 0%, var(--bg-soft) 100%);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

/* IN A MODAL, THE MODAL IS ALREADY THE CARD. `.modal-card .auth-card` strips the
   background, border and shadow for exactly that reason — a card inside a card
   is two rounded rectangles and a double shadow — and it is RIGHT, so this does
   not override it wholesale. It restores the tint alone, at the same specificity
   and later in the file, and deliberately leaves border and shadow stripped. */
.modal-card .registration-fee {
  background: linear-gradient(180deg, var(--card) 0%, var(--bg-soft) 100%);
}

/* THE FIGURE IS THE POINT OF THE SCREEN, so it gets the accent rule and a
   slightly deeper inset than the ordinary .form-note it shares a class with.
   The left border is the carrier — a full tinted panel would compete with the
   Pay button, which is the thing that should win the eye last. */
.registration-fee .signup-handle {
  background: var(--card);
  border: 1px solid var(--border);
  border-left: 4px solid var(--accent-2);
  border-radius: 10px;
  padding: 12px 14px;
  line-height: 1.55;
}

/* THE PAYMENT AREA AS ITS OWN SURFACE. It was a hairline rule and nothing else,
   which left the provider choice and the Pay button floating in the same space
   as the reference line above them. Giving it a panel groups the three things
   that are one decision, and separates them from the notice that explains the
   charge. */
.registration-fee .pay-block {
  margin-top: 16px;
  padding: 14px;
  border: 1px solid var(--border);
  border-top: 1px solid var(--border);
  border-radius: 12px;
  background: var(--card);
}

/* "Pay with" — the legend over the provider choice. It was the same dim grey as
   the explanatory notes, so the one instruction on the panel read as another
   aside. */
.registration-fee .choice-field legend {
  font-weight: 700;
  color: var(--text);
  letter-spacing: 0.01em;
}

/* The provider pills, given room to be tapped and something to say on hover.
   Bigger than the shared .choice because this is a phone-first decision made
   once, not a filter flicked through. */
.registration-fee .choice {
  padding: 0.75rem 0.9375rem;
  border-radius: 12px;
  background: var(--card);
  transition: border-color 0.15s ease, box-shadow 0.15s ease,
              transform 0.15s ease, background-color 0.15s ease;
}

@media (hover: hover) {
  /* LIFTS, and only where a pointer exists. On a touch screen :hover sticks
     after a tap and would leave the unchosen provider looking chosen. */
  .registration-fee .choice:hover {
    border-color: var(--accent);
    box-shadow: 0 4px 14px rgba(108, 92, 231, 0.16);
    transform: translateY(-1px);
  }
}

/* The chosen one is stated rather than implied, because the button below names
   a provider and the two must agree at a glance. */
.registration-fee .choice.checked {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.14);
}

/* An offered-but-unusable provider — Airtel today. Dimmed rather than hidden:
   the customer asked for it to be on the screen, and a wallet that vanished
   would read as a missing feature rather than as one that is coming. */
.registration-fee .choice[aria-disabled="true"] {
  opacity: 0.72;
}

@media (hover: hover) {
  .registration-fee .btn-primary:hover {
    box-shadow: 0 6px 18px rgba(108, 92, 231, 0.28);
    transform: translateY(-1px);
  }
  .registration-fee .btn.is-danger:hover {
    box-shadow: 0 6px 18px rgba(192, 57, 43, 0.26);
    transform: translateY(-1px);
  }
}

.registration-fee .btn-primary,
.registration-fee .btn.is-danger {
  transition: box-shadow 0.15s ease, transform 0.15s ease, filter 0.15s ease;
}

/* THE TWO BUTTONS MUST NOT TOUCH. Pay and Quit sit one above the other and a
   mis-tap between them is the worst outcome this screen has. */
.registration-fee .btn.is-danger { margin-top: 10px; }

/* MOTION IS A PREFERENCE, and every lift above is decoration. Honouring this is
   not politeness — for a vestibular disorder the transform is the symptom. */
@media (prefers-reduced-motion: reduce) {
  .registration-fee .choice,
  .registration-fee .btn-primary,
  .registration-fee .btn.is-danger {
    transition: none;
  }
  .registration-fee .choice:hover,
  .registration-fee .btn-primary:hover,
  .registration-fee .btn.is-danger:hover {
    transform: none;
  }
}

/* ==== END: The registration fee screen ===================================== */

.pay-block {
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

/* The glyph and the wordmark as one inline run, so the radio in .choice aligns
   against the pair rather than against the glyph alone. */
.pay-option {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
}

/* <Icon /> draws at 1em, so font-size here IS the glyph's size. */
.pay-glyph {
  font-size: 1.05rem;
  flex: 0 0 auto;
}

.pay-name {
  font-weight: 700;
  letter-spacing: 0.02em;
}

.pay-name-mpesa { color: var(--pay-mpesa); }
.pay-name-airtel { color: var(--pay-airtel); }

/* The selected provider's one-line explanation, and the status copy for the
   waiting / gave-up / paid states. Same size and ink as .cart-note, which is
   the note directly above it — two notes in one panel that differed would read
   as two different kinds of statement. */
.pay-note {
  margin: 8px 0 0;
  color: var(--text-dim);
  font-size: 0.75rem;
  line-height: 1.5;
}

/* The one line that says what is happening: "waiting for you to approve",
   "payment received", "we stopped waiting". Full ink and not dim, because it is
   the answer to the only question being asked on this screen. */
.pay-headline {
  margin: 8px 0 0;
  color: var(--text);
  font-size: 0.86rem;
  font-weight: 600;
  line-height: 1.45;
}

.pay-error {
  margin: 8px 0 0;
  color: var(--danger);
  font-size: 0.78rem;
  line-height: 1.45;
}

/* Success takes a tint, unlike every other state here, because it is the one
   the customer is looking for and the one they should be able to find without
   reading. Left rule and 8% wash, the same language .admin-confirm-warn uses
   for the opposite outcome — but in the accent rather than the danger. */
.pay-paid {
  padding: 12px 14px;
  border-top: 0;
  border-left: 3px solid var(--pay-mpesa);
  border-radius: 0 8px 8px 0;
  background: rgba(31, 110, 44, 0.08);
}

/* THE PAYMENT-RECEIVED ALERT, layered ON TOP of .pay-paid rather than replacing
   it — the element carries `pay-block pay-paid pay-settled`, so the green wash,
   the 3px --pay-mpesa rule and the square left corners all still come from the
   rule above and are not restated here. This adds only what makes it an alert
   rather than a note.

   CENTRED, WHICH NOTHING ELSE IN THIS PANEL IS. Every other block here is read
   left-to-right as part of a form; this one is a full stop. It is on screen for
   PAYMENT_SETTLED_SECONDS and then the page leaves by itself, so it has to be
   takeable in at a glance rather than read — hence the glyph, the size jump on
   the title, and the axis change that stops it looking like one more paragraph
   in the stack it replaced.

   PADDING OVERRIDES .pay-paid's 12px 14px. That figure is right for two lines
   of copy in a panel of other panels; this one holds five elements including a
   button and a 2.4rem glyph, and at 12px the glyph sits almost on the rule.

   NOTHING HERE SETS A COLOUR, and that is deliberate rather than an omission.
   The wash is #edf2e7 (--pay-mpesa at 8% over --card) and every ink that lands
   on it already clears AA: --text 12.97:1 for .pay-headline and the title,
   --text-dim 4.98:1 for .pay-note and the countdown, --pay-mpesa 5.53:1 for the
   glyph and the rule. Re-tinting the title green would have cost the 12.97:1 to
   buy 5.53:1 of the same signal the panel is already sending, which is the
   argument .admin-confirm-warn makes for keeping its own ink at --text. */
.pay-settled {
  padding: 20px 18px 18px;
  text-align: center;
}

/* The tick. 2.4rem on a glyph <Icon /> renders at 1em, so the size IS the
   font-size — see the note above ICON_SHAPES.check for why it is scaled this
   way rather than by thickening the stroke. Coloured explicitly because <Icon
   plain /> takes currentColor, which here would be the inherited --text and
   would make the one celebratory mark on the site the same grey-green as the
   prose under it. */
.pay-settled-mark {
  font-size: 2.4rem;
  color: var(--pay-mpesa);
}

/* "Order Placed Successfully!" — the largest type in this panel and the only
   place the site raises its voice.

   A <p> WITH A CLASS, NOT AN <h3>. The modal already owns the heading level
   here (<h2>Order placed</h2> at the top of .cart-panel), and this appears
   several sections below it: an <h3> would put a heading in the document
   outline for a message that is gone in five seconds, and a screen reader
   walking headings afterwards would find one for a panel that no longer exists.
   role="status" on the container is what announces it, which is the right
   mechanism for a transient message. */
.pay-settled-title {
  margin: 8px 0 4px;
  font-size: 1.22rem;
  font-weight: 700;
  line-height: 1.25;
  letter-spacing: -0.01em;
  color: var(--text);
}

/* The countdown. Deliberately the quietest line in the panel — it is
   aria-hidden and exists only to explain why the page is about to move, so it
   must not compete with the reference above it. Tabular figures so the digit
   changing from 5 to 4 does not shift the line's width, which at this size is
   visible as a twitch once a second. */
.pay-settled-count {
  margin: 10px 0 0;
  color: var(--text-dim);
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
}

/* Pay on delivery, recorded on the order.

   TINTED LIKE .pay-paid ABOVE, BECAUSE IT IS ALSO AN OUTCOME THE CUSTOMER IS
   LOOKING FOR — the same argument that rule makes for not leaving success as
   plain copy. In the SUNSET rather than the green, and that is the whole point
   of it being a separate rule: this order is NOT paid, no money has moved, and
   a green wash is the one thing on this screen that would say otherwise. It
   also matches the button that got here, which is .btn-sunset.

   THE RULE IS --sunset-3 AND THE WASH IS THE SAME COLOUR AT 8%, mirroring
   .pay-paid's construction exactly (3px rule, 8% wash, square left corners) so
   the two states are recognisably the same KIND of panel in different colours.
   #f2802a at 8% over --card computes to roughly #fef6ef, which leaves --text on
   it at 13.9:1 and --text-dim at 5.6:1 — both clear AA comfortably, so the copy
   inside needs no colour of its own. */
.pay-arranged {
  padding: 12px 14px;
  border-top: 0;
  border-left: 3px solid var(--sunset-3);
  border-radius: 0 8px 8px 0;
  background: rgba(242, 128, 42, 0.08);
}

.pay-block .btn { margin-top: 12px; }

/* THE ALTERNATIVE-NUMBER FIELD NEEDS A GAP ABOVE IT, and .field does not carry
   one: it is `margin-bottom: 16px` with nothing on top, which is right in a
   form where fields stack and each one's bottom margin is the gap to the next.
   Here it follows .pay-error, whose own margin is also top-only — so the two
   met exactly, and the label "Pay from a different M-PESA number" sat hard
   against "M-Pesa rejected the request…" and read as a second line of the
   error rather than the start of a control. Measured at 1280px before this
   rule: 0px between them.

   12px AND THE SAME DESCENDANT SHAPE AS .pay-block .btn ABOVE, so the field is
   spaced off the error by the same step the buttons are spaced off each other
   and the block keeps one rhythm. The bottom margin is left alone: .field's
   16px and the button's 12px are adjacent siblings in normal flow, so they
   collapse to 16px, which is the gap this panel already uses between a note and
   the control under it. */
.pay-block .field { margin-top: 12px; }

/* A HOUSE DEFAULT, WEARING ITS OWN COLOUR. Added to whichever figure on a
   product card came from 026's defaults rather than from the manufacturer —
   .product-pack, or either <dd> in .product-prices — so a defaulted pack size
   and a real one are never the same colour on the same card.

   THE MARK IS THE INK AND A DOTTED UNDERLINE, not a background. A tint behind
   the figure would fight .ph-bar, which is filled with --bg-soft and is the
   card's existing language for "this datum has not arrived"; a card can carry
   both at once — co-24 has a defaulted pack AND pending release figures — and
   two filled shapes side by side would read as one state. Ink plus a rule under
   it is a different sentence in the same voice.

   THE UNDERLINE IS WHY THIS IS NOT COLOUR ALONE. 1.4.1 asks that colour never
   be the only carrier, and "this number is a guess" is exactly the kind of thing
   a red-green reader must not lose. The dotted rule says it without colour, the
   title attribute says it in words, and the hue is the fast path for everybody
   else. text-underline-offset keeps the dots clear of the descender on the
   comma in "1,000".

   FONT-WEIGHT IS UNTOUCHED. emphasisClass() already sets the weight on these
   figures and this rule is appended to it, so a defaulted trade price keeps
   whatever emphasis its row had and gains a mark rather than swapping one. */
.is-provisional {
  color: var(--provisional);
  text-decoration: underline dotted var(--provisional);
  text-underline-offset: 3px;
}

/* ONE PROVIDER PER ROW, which the shared .choice-group does not give once there
   is more than one of them. That rule is
   `repeat(auto-fit, minmax(140px, 1fr))`, so two payment pills sit SIDE BY SIDE
   from about 300px of panel width up — measured at 477px, the narrow case, and
   they were still beside each other. Airtel was asked for BELOW M-PESA, and
   this is what puts it there at every width.

   A DESCENDANT SELECTOR RATHER THAN A MODIFIER CLASS, for two reasons.
   <ChoiceField /> takes no className, so a modifier would mean widening its
   signature for one caller; and .pay-block holds exactly one .choice-group —
   <PaymentChoice />'s — while the delivery group on step one and the account
   -type group in the signup form sit outside it and keep the shared behaviour,
   which is right for them: three short delivery labels across one row is the
   layout that rule exists for.

   IT BEATS .choice-group ON SPECIFICITY (0,2,0 against 0,1,0), not on order, so
   it holds even though the rule it overrides is declared several thousand lines
   further down this file. Stated because the reverse would be a silent
   failure. */
.pay-block .choice-group { grid-template-columns: 1fr; }


/* ==== END: Cart panel ====================================================== */


/* ==== BEGIN: Product docs (leaflet / images) =============================== */
/* The modal opened by either link on a product card. Two views behind one set
   of tabs, sharing the modal plumbing. */

.docs-panel { padding: 24px 22px 26px; }

/* Tinted by therapeutic class like the card's plate, so the modal reads as the
   same product. Right padding clears the modal close button. */
.docs-head {
  margin: -24px -22px 18px;
  padding: 20px 56px 16px 22px;
  background: var(--tc-tint);
  border-bottom: 1px solid var(--tc-edge);
}

/* Inherits the mark's face from .product-brandmark; only the size differs. */
.docs-head .product-brandmark {
  margin: 3px 0 5px;
  font-size: calc(1.5rem * var(--mk-scale));
  text-align: left;
}

.docs-head .docs-generic { margin-bottom: 9px; }

.docs-generic {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.85rem;
  line-height: 1.45;
}

.docs-tabs {
  display: flex;
  gap: 4px;
  padding: 6px;
  background: var(--bg-soft);
  border-radius: 10px;
  margin-bottom: 18px;
}

.docs-tab {
  flex: 1;
  padding: 0.5rem;
  border: none;
  border-radius: 8px;
  background: transparent;
  color: var(--text-dim);
  font-weight: 600;
  font-size: 0.85rem;
  transition: all 0.15s ease;
}

.docs-tab.active {
  background: var(--card);
  color: var(--text);
  box-shadow: 0 1px 3px rgba(28,46,32,0.14);
}

/* -- Leaflet view ---------------------------------------------------------- */

/* Each result sits above the specification it is judged against. */
.spec-table {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.625rem 0.875rem;
  margin: 0 0 18px;
  font-size: 0.88rem;
}

.spec-table dt {
  color: var(--text-dim);
  font-weight: 600;
}

.spec-table dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

.spec-table dd span {
  display: block;
  margin-top: 2px;
  color: var(--text-dim);
  font-size: 0.75rem;
  font-variant-numeric: normal;
}

.leaflet-sections {
  list-style: none;
  margin: 0 0 16px;
  padding: 0;
  border-top: 1px solid var(--border);
}

.leaflet-sections li {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.5625rem 0;
  border-bottom: 1px solid var(--border);
  font-size: 0.85rem;
}

.leaflet-sections span {
  flex-shrink: 0;
  color: var(--text-dim);
  font-size: 0.74rem;
}

/* -- Images view ----------------------------------------------------------- */

/* Two columns, because a listing carries exactly two photographs — primary
   pack and secondary pack, PRODUCT_IMAGE_SLOTS. It was three, for three
   loosely-named placeholder tiles; two real slots at half the modal's width
   are also meaningfully bigger than three were. */
.docs-images {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
  margin-bottom: 16px;
}

/* THE TWO TILES ARE DIFFERENT HEIGHTS NOW, and this is what stops that reading
   as a mistake. Each frame takes its own photograph's shape (see .pack-image),
   so a portrait carton beside a landscape blister strip gives one tall tile and
   one short one — which is correct, and was the whole point of removing the
   letterbox, but left the two captions on different lines like a table that had
   come loose.

   The grid stretches both figures to the taller one's height; space-between
   then pins each photograph to the top of its cell and each caption to the
   bottom of it, so the pictures share a top edge and the words share a
   baseline. The slack lands between them, inside the cell, where it reads as
   the layout breathing rather than as one tile having slipped. */
.docs-image {
  margin: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

/* Sizing only. The surface, the dashed empty state and the fit rule all live
   on .pack-image, which this composes with — one photograph box, styled once,
   wherever it appears.

   flex-shrink: 0 because the frame's height comes from its aspect-ratio, and a
   flex item's default is to give that height up under pressure — which on the
   shorter of two tiles would squash the picture out of the shape it was just
   measured into. There is no pressure to give in to anyway: the cell is as tall
   as the TALLEST tile, so every frame in it already fits. */
.docs-image-box {
  width: 100%;
  flex-shrink: 0;
}

.docs-image figcaption {
  margin-top: 6px;
  color: var(--text-dim);
  font-size: 0.7rem;
  line-height: 1.3;
  text-align: center;
}

/* What belongs in an empty slot, under the slot's name. Block, so the name
   stays on its own line and reads as the label rather than as the start of a
   sentence the hint finishes. */
.docs-image-hint {
  display: block;
  margin-top: 3px;
  font-size: 0.65rem;
  opacity: 0.85;
}

/* -- Shared footnote ------------------------------------------------------- */

.docs-note {
  margin: 0;
  padding-top: 0.75rem;
  border-top: 1px solid var(--border);
  color: var(--text-dim);
  font-size: 0.75rem;
  line-height: 1.55;
}

/* ==== END: Product docs (leaflet / images) ================================= */


/* ==== BEGIN: Pack photography ============================================== */
/* One box for every pack shot in the interface — the card's plate and the two
   tiles in the docs modal — and the layer a click on one opens. Styled once
   here rather than per caller, so the fit rule below is stated once. */

/* The box. Shaped by aspect-ratio rather than by a fixed height, so it keeps
   its proportions at every width its container gives it and needs no breakpoint
   of its own.

   THE RATIO IS THE PHOTOGRAPH'S OWN. --pack-aspect is written onto this element
   by <PackImage /> the moment the file decodes, so the frame takes the shape of
   the picture in it and `contain` on .pack-image-img has nothing left to
   letterbox — the whole photograph, uncropped, meeting all four edges. That is
   what "occupy the entire frame without trimming" asks for, and the two halves
   of it are split across the two rules deliberately: this one removes the empty
   space, the one below is what guarantees nothing is ever cut off.

   1 IS THE FALLBACK, not the value. A square is what an empty slot is, what an
   image that has not decoded yet gets, and what a broken file falls back to.
   var()'s default holds all three from one place — see packAspectStyle(), which
   sends no property at all rather than sending 1 itself.

   It is a <button> when the photograph enlarges and a <div> when there is
   nothing to enlarge, so the button reset is part of the base rule — a browser
   default border and centred text would otherwise show through on half the
   instances and on neither of the others. */
.pack-image {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: var(--pack-aspect, 1);
  margin: 0;
  padding: 0;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: 9px;
  background: var(--card);
  font: inherit;
  color: inherit;
  text-align: inherit;
  -webkit-appearance: none;
  appearance: none;
}

/* CONTAIN, NEVER COVER — the whole of "critical attributes must not be
   trimmed", in one keyword.

   `cover` would fill the box edge to edge by scaling the photograph up and
   cutting off whatever overflows. On a pack shot that is the edges, and the
   edges are where the strength, the batch number, the expiry and the
   manufacturer's name are printed: a centre crop of a carton keeps the brand
   and loses the dose. The uploader resizes by the same rule — see
   PRODUCT_IMAGE_FIT.

   IT IS STILL `contain` NOW THE FRAME FITS THE PICTURE, and swapping it for
   `cover` — which would look identical almost all of the time — is the one
   change to this block that must never be made. The frame above matches the
   photograph, so there is normally nothing for either keyword to do; the
   difference is entirely in what happens when they DISAGREE. They disagree
   whenever the measurement has not landed, whenever PACK_ASPECT's clamp binds
   on a freak ratio, and on any browser that ignores the custom property. Under
   `contain` all three letterbox — visibly nothing, obviously nothing. Under
   `cover` all three would silently crop a carton, and there would be no way to
   tell from the tile that a dose had been cut off it. The frame is a
   convenience; this line is the promise. */
.pack-image-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* Nothing uploaded to this slot. Dashed, which is this app's placeholder
   language everywhere else — the empty-state panels and the catalogue-less
   card grid all use it — so an unphotographed slot reads as pending rather
   than as broken. */
.pack-image.is-empty {
  border-style: dashed;
  border-color: rgba(28,46,32,0.18);
  background: var(--bg-soft);
}

.pack-image.is-zoomable { cursor: zoom-in; }

@media (hover: hover) {
.pack-image.is-zoomable:hover { border-color: var(--tc-edge, var(--border)); }
}

.pack-image.is-zoomable:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* The corner glyph saying the picture opens. Small, and in the corner rather
   than over the middle, because the middle of a pack shot is the label. It is
   aria-hidden — the button's own label says "Enlarge …", so a screen reader is
   told this in words and does not need the arrow read to it as well. */
.pack-image-cue {
  position: absolute;
  right: 5px;
  bottom: 5px;
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 6px;
  background: rgba(28,46,32,0.62);
  color: #fff;
  font-size: 0.66rem;
  line-height: 1;
}

/* .product-shot RETIRED 2026-08-31. It capped the primary pack shot at 118px on
   the card's plate. The plate carries no photograph now — see <ProductCard /> —
   so the class is set by nothing and the rule is gone with it rather than left
   to report as dead CSS in section 2 of tools/verify-clean.ps1.

   The cap it applied is not needed anywhere else: the two places a pack shot is
   still shown size themselves from their container (.docs-image-box) or from
   the viewport (.zoom-img). */

/* -- The enlarged view ----------------------------------------------------- */

/* Above .modal-overlay's 200, since a zoom opened from the images view is a
   layer over that modal rather than a replacement for it. Fixed and inset: 0.
   Opened from inside the modal it is contained by .modal-overlay instead of by
   the viewport — `backdrop-filter` there makes that element a containing block
   for fixed descendants — but that overlay is itself fixed and inset: 0, so
   the two rectangles are the same one and the layer lands correctly either
   way. Verified in the browser at three viewport widths, not assumed. */
.zoom-layer {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 24px;
  background: rgba(18,30,20,0.84);
  cursor: zoom-out;
}

/* Focus is moved here on open so the layer sees Escape before the modal
   underneath does; the ring would be a rectangle around the whole screen. */
.zoom-layer:focus { outline: none; }

.zoom-figure {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  max-width: 100%;
  margin: 0;
}

/* Capped at PRODUCT_IMAGE_BOX's 1000px as well as at the viewport, so the
   photograph is never displayed larger than it is stored and never upscaled
   into softness at the one moment somebody is looking closely. object-fit
   repeats contain for the case where both caps bind at once. */
.zoom-img {
  display: block;
  max-width: min(92vw, 1000px);
  max-height: 82vh;
  object-fit: contain;
  border-radius: 10px;
  background: var(--card);
  box-shadow: 0 30px 70px rgba(0,0,0,0.45);
}

.zoom-caption {
  color: rgba(255,255,255,0.92);
  font-size: 0.82rem;
  text-align: center;
}

/* Top right, clear of the picture. Every click on this layer closes it, so
   this button carries no handler of its own — it is the affordance for a
   behaviour the whole backdrop already has, and the one thing a customer who
   does not know that will look for. */
.zoom-close {
  position: absolute;
  top: 16px;
  right: 16px;
  width: 2.125rem;
  height: 2.125rem;
  border: 1px solid rgba(255,255,255,0.28);
  border-radius: 50%;
  background: rgba(255,255,255,0.12);
  color: #fff;
  font-size: 0.9rem;
  line-height: 1;
  cursor: pointer;
}

@media (hover: hover) {
.zoom-close:hover { background: rgba(255,255,255,0.22); }
}

.zoom-close:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

/* ==== END: Pack photography ================================================ */


/* ==== BEGIN: Auth card ===================================================== */
/* Rendered inside the modal, opened from the navbar's "Sign in" button. */

.auth-card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 26px 24px 24px;
  min-width: 0;
  width: 100%;
}

/* The way back to the cart, at the top of the card. A button, not a link: it
   changes what this modal is showing and navigates nothing, so an <a> with no
   href would be a control announced as a link that goes nowhere.

   Quiet on purpose — dim, small, no border. It is an escape hatch, not a call
   to action, and the primary button on this card is the one that signs you in.

   44px of touch target with only ~20px of ink, bought with negative margins so
   the visible text still lines up with the heading's left edge rather than
   sitting indented by its own padding. The phone is where this button matters
   most: a mis-tap next to it lands on the Username field, which is harmless,
   whereas a mis-tap on a 20px target lands on nothing and reads as the button
   being broken. */
.auth-back {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  margin: -0.5rem 0 0.625rem -0.5rem;
  padding: 0.5rem;
  border: 0;
  background: none;
  color: var(--text-dim);
  font: inherit;
  font-size: 0.82rem;
  font-weight: 600;
  cursor: pointer;
  border-radius: 8px;
}

@media (hover: hover) {
  .auth-back:hover { color: var(--accent-2); }
}

.auth-back:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* Its own element rather than a ::before, so it can be aria-hidden. An arrow
   read out as "leftwards arrow" in front of "Back to your cart" is noise. */
.auth-back-arrow {
  font-size: 0.95rem;
  line-height: 1;
}

.auth-head { margin-bottom: 20px; }

.auth-head h2 {
  margin: 0 0 6px;
  font-size: 1.3rem;
}

.auth-head p {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.88rem;
  line-height: 1.5;
}

/* A MODIFIER, so one screen can centre its heading without every .auth-head
   moving. The fee step is the only one whose head is a bare step counter with
   no subtitle under it, and a lone short heading ranged left over a wide card
   reads as the start of a paragraph that never arrives. */
.auth-head.is-centred { text-align: center; }

.auth-form { margin: 0; }

/* "New to Savannah Pharmaceuticals? Create an account" — the button inside is a .link-btn. */
.auth-switch {
  margin: 18px 0 0;
  text-align: center;
  font-size: 0.88rem;
  color: var(--text-dim);
}

/* Shown after a form validates. --accent-2 at low alpha rather than the old
   dark-theme teal (#00d4b5), which this light theme dropped for failing
   contrast on cream. */
.auth-success {
  padding: 12px 4px 8px;
  text-align: center;
}

.auth-success-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.875rem;
  height: 2.875rem;
  margin: 0 auto 14px;
  border-radius: 50%;
  background: rgba(10,122,104,0.12);
  border: 1px solid rgba(10,122,104,0.38);
  color: var(--accent-2);
  font-size: 1.3rem;
  font-weight: 700;
}

.auth-success h2 {
  margin: 0 0 8px;
  font-size: 1.15rem;
}

.auth-success p {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.88rem;
}

/* -- One-time code step ---------------------------------------------------- */
/* The screen between the form and the session. It reuses .auth-card, .auth-head,
   .auth-form and .auth-switch wholesale — it IS the auth card, on its second
   page — and adds only what is specific to a code: the panel that prints one, and
   a wider tracked field to type it into.

   Scoped under .otp-step rather than given the input its own class, because
   <Field /> takes no className and should not grow one for a single caller. */

/* The code is six digits typed as a group, not prose: tracked, centred and
   larger, which is also what makes a transposed digit visible while typing. */
.otp-step .field input {
  text-align: center;
  font-size: 1.2rem;
  font-weight: 700;
  letter-spacing: 0.4em;
  /* Half the tracking again on the right, or the trailing letter-space pushes the
     group visibly left of centre. */
  padding-right: calc(0.8125rem + 0.4em);
}

/* `.otp-issued`, `.otp-issued-label` and `.otp-code` ARE GONE, with the panel
   they dressed. It printed the six-digit code on the reset screen under the
   caption "Code, shown because nothing was sent" — a stand-in for an SMS that
   was never sent, deliberately given --danger at low alpha over a dashed border
   so that the one dishonest thing on the screen was not also the most
   reassuring. AUTH_SHOW_OTP is retired: the code goes to the mobile number on
   the account and the page never sees it, so there is nothing left to dress.

   Deleted rather than left behind, for the reason the note below gives — this
   stylesheet is audited in both directions and a rule nothing names is exactly
   what the dead-CSS pass is for. Two comments elsewhere borrowed this panel's
   reasoning (`.leaflet-draft`, `.auth-failure`) and now make the argument on
   their own rather than pointing at a rule that no longer exists. */

/* `.otp-wait` IS GONE with the resend countdown it sized. <OtpStep /> had a
   30-second gate before "Send a new code" became available; <ResetStep />, which
   replaced it, has no resend at all — a customer who did not get the text goes
   back and asks again, which re-enters through the username screen and is one
   fewer piece of state to hold. Deleted rather than left, because verify-clean
   audits the stylesheet in both directions and a rule nothing names is exactly
   what its dead-CSS pass is for. */

/* Two stacked text buttons in one .auth-switch, which is centred and sized for
   one line of prose. */
.otp-step .auth-switch .link-btn { margin-top: 4px; }

/* -- The sign-up form's "everything is needed" note ------------------------- */
/* One line above the first field, replacing an asterisk on eleven captions.
   Sized and coloured like .field-hint, which is what it is — a note that carries
   no failure — but it needs its own bottom margin because it stands above a
   field rather than under one, and .field-hint's `margin-top: 5px` would leave
   it welded to the caption below. */
.form-note {
  margin: 0 0 16px;
  color: var(--text-dim);
  font-size: 0.78rem;
  line-height: 1.45;
}

/* The line telling a customer what their username will be. It is a .form-note
   with a box, because it is the one sentence on the sign-up form that they have
   to carry away with them — nobody CHOSE this handle, so nobody will remember
   it, and a note that reads like the other grey prose on the page is a note
   that gets scrolled past.

   --accent-2 at low alpha rather than the --danger family .auth-failure uses:
   this is information the customer needs, not a refusal.

   It sits BELOW the password fields and above the terms, so it appears at the
   moment the credential is complete rather than beside a half-filled form. */
.signup-handle {
  margin: -4px 0 18px;
  padding: 0.625rem 0.75rem;
  border: 1px solid rgba(10,122,104,0.38);
  border-left-width: 3px;
  border-radius: 8px;
  background: rgba(10,122,104,0.07);
  color: var(--text);
  font-size: 0.82rem;
}

/* The handle itself, which is the whole reason the box exists. Tabular so a
   registration number's digits sit on an even pitch, and breakable because
   P00000000D00000000 has nowhere to wrap on a narrow phone. */
.signup-handle strong {
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}

/* -- Terms layer ----------------------------------------------------------- */
/* The Terms of Sale opened from the sign-up form's consent row, ON TOP of the
   auth modal rather than instead of it — see <TermsLayer />. The construction is
   .zoom-layer's, for the same two reasons: the modal card underneath is 440px,
   which is no width for a contract, and the layer has to own Escape so one press
   closes the document and not the half-filled form behind it.

   z-index 300 matches .zoom-layer. The two can never be on screen together — one
   opens from a product card, the other from inside the auth dialog — so they are
   peers rather than a stack, and giving this one a higher number would imply an
   ordering that has no case. */
.legal-layer {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  padding: 24px 16px;
  overflow-y: auto;
  background: rgba(18,30,20,0.84);
}

/* Focus is moved here on open so the layer sees Escape before the modal
   underneath does; the ring would be a rectangle around the whole screen. */
.legal-layer:focus { outline: none; }

/* `margin: auto` on a flex child centres it on both axes AND still lets it grow
   past the viewport height into the scroll — which `align-items: center` does
   not: that clips the top of an over-tall child beyond reach. A contract is
   always over-tall.

   820px is .modal-card-wide's width, chosen there for this exact content: a
   clause at roughly 90 characters a line, and room for Schedule 1's seven
   columns. */
.legal-layer-card {
  position: relative;
  width: 100%;
  max-width: 820px;
  margin: auto;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 24px 60px rgba(28,46,32,0.28);
}

/* Same control, same place and same size as .modal-close — this layer is a
   dialog and should not invent a second way to shut one. It is not the same
   RULE because .modal-close is scoped to the modal card, and widening that
   selector to cover both would couple two components that are deliberately not
   the same one. */
.legal-layer-close {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 1;
  width: 1.875rem;
  height: 1.875rem;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--bg-soft);
  color: var(--text);
  font-size: 0.85rem;
  display: flex;
  align-items: center;
  justify-content: center;
}

@media (hover: hover) {
.legal-layer-close:hover { border-color: var(--accent); }
}

/* A second way out at the BOTTOM, because the first one is 1,400 lines above it.
   Somebody who has actually read to the end should not have to scroll back to
   the top to say so — and on a phone the close button is off-screen for the
   entire document. */
.legal-layer-foot {
  margin: 0;
  padding: 0 26px 26px;
}

/* -- Account dashboard ------------------------------------------------------ */
/* The signed-in half of the navbar's Account button: who the account says you
   are, and your own order history. It supplies its own padding for the same
   reason .legal-doc and .contact-panel do — the modal card has none — with the
   same 46px of top clearance that keeps a heading out from under the
   absolutely-positioned close button.

   IT IS A COLUMN, NOT A FLOW, since 2026-08-29: the card above it is capped at
   90vh and something inside has to give. The head takes the height it wants and
   the order history takes the rest; see the two rules below for which is which. */
.account-dash {
  padding: 46px 24px 24px;
  display: flex;
  flex-direction: column;
  /* Without this a flex child refuses to shrink below its content height, the
     column grows past the card, and `overflow: hidden` on .modal-card-tall
     clips the bottom off instead of scrolling it. It is the single line that
     makes the whole arrangement work and the single line easiest to drop. */
  min-height: 0;
  /* THE PANEL ITSELF NO LONGER SCROLLS — .account-dash-scroll below does, and
     the Sign out row is deliberately outside it. `hidden` here is what makes
     that pinning real: without it the footer would push the column past the
     card and .modal-card-tall would clip it off.

     The overflow that used to live here was `auto`, and the note with it warned
     that `hidden` "would have been a promise that the two children between them
     always fit". That promise is now kept by the child that scrolls rather than
     by the panel, so nothing is clipped that cannot be reached. */
  overflow: hidden;
}

/* The two rows that scroll: the head and the order history. Everything the old
   `overflow-y: auto` on .account-dash did, one level in, so that the footer
   after it stays on screen. A flex column in its own right, because
   .account-dash-head and .account-orders are still sized against each other —
   0 0 auto against 1 1 auto — and that arrangement has to survive the move. */
.account-dash-scroll {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* THE HEAD — name, and the details table. IT DOES NOT SHRINK, and that is the
   whole of this rule.

   `0 1 auto` was tried first and squeezed it on an ORDINARY DESKTOP: flexbox
   shares a shortfall in proportion to each item's BASIS, the list's basis is
   the natural height of two dozen rows, and at 1280x720 the head duly
   surrendered 295 of its 389px to a deficit it had no business paying for. The
   details are the part always worth showing whole — eight short rows that do
   not change, and what the customer opened the panel to read.

   So the list absorbs the shortfall down to the floor on .order-list, and only
   past that does the panel itself scroll. */
.account-dash-head {
  flex: 0 0 auto;
}

.account-section {
  margin-top: 24px;
  padding-top: 20px;
  border-top: 1px solid var(--border);
}

.account-section h3 {
  margin: 0 0 14px;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* THE GROWING HALF. `1 1 auto` takes the leftover height and the column is what
   keeps the heading and the "Show more" button still while .order-list moves
   between them.

   THE FLOOR WAS `min-height: 0` AND THAT WAS THE BUG. .order-list carries a
   7rem floor of its own, deliberately — see the note there — but a parent that
   may shrink to zero cannot honour a child that may not. On a short viewport
   this section collapsed to a few pixels, the list overflowed a box that clips
   nothing, and the orders were painted straight over the Sign out row below:
   reported on 2026-08-30 as a misplaced Sign out button, which is what it looks
   like when a list is drawn on top of it.

   11rem is the list's own floor plus the "Order history" heading and its
   margins. Past that the shortfall goes where the .account-dash comment always
   said it would — the panel itself scrolls — and everything stays in its own
   row. The list still absorbs every shortfall ABOVE the floor first, so an
   ordinary desktop is unchanged. */
.account-orders {
  flex: 1 1 auto;
  min-height: 11rem;
  display: flex;
  flex-direction: column;
}

/* Sign out, on its own row of the column. `0 0 auto` so it is never the item
   flexbox decides to shrink, and a top rule because it is the panel's footer
   rather than another section of it. Was a bare `.auth-switch` paragraph with
   no flex declaration at all.

   OUTSIDE THE SCROLLER, not sticky inside it. Sticky was tried first and it
   reproduced the reported fault rather than fixing it: stuck to the bottom of
   a scrollport that is itself inset by the panel's padding, the row rendered
   OVER the order list with rows still visible below it — the same "Sign out
   floating in the middle of the history" the screenshot showed. As the last
   row of a column whose scrolling happens one level in, it is simply the
   bottom of the card at every height, with no stacking to reason about.

   Measured at 915x412 — a phone held sideways, the viewport the report came
   from — the card is 370px and the head plus a floored list wants more, so the
   part above this scrolls and this row does not move. */
.account-dash-foot {
  flex: 0 0 auto;
  margin-top: 18px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
  text-align: center;
  font-size: 0.88rem;
}

/* The details block is a <Disclosure /> now, so the section wants no rule of
   its own beyond losing the padding a heading used to need: the toggle IS the
   heading, and it draws its own box. */
.account-details-section {
  padding-top: 16px;
}

/* A <dl> of name/value pairs. The grid is on each PAIR rather than on the list,
   which is what lets a long value wrap under its own label instead of dragging
   the whole column wider — a premises name runs to forty characters and a
   registration number to eleven. */
.account-details {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.account-detail {
  display: grid;
  grid-template-columns: minmax(0, 8.5rem) minmax(0, 1fr);
  gap: 4px 12px;
  align-items: baseline;
}

.account-detail dt {
  color: var(--text-dim);
  font-size: 0.8rem;
}

.account-detail dd {
  margin: 0;
  font-size: 0.9rem;
  font-weight: 600;
  /* A registration number and an email both overflow a narrow column, and
     neither has a space to break at. */
  overflow-wrap: anywhere;
}

/* Below 380px the two columns cannot both hold their content, so the label
   stacks above its value rather than squeezing to three characters. A
   breakpoint here rather than a flex basis, because a <dl> grid has no
   equivalent of .field-pair's self-stacking trick. */
@media (max-width: 380px) {
  .account-detail { grid-template-columns: minmax(0, 1fr); }
}

/* -- Order history ---------------------------------------------------------- */

/* THE SCROLLER. The list is the only part of the panel that has no length worth
   designing around — a customer with four orders and one with four hundred get
   the same card — so this is where the overflow belongs, and the head above it
   stays put while it moves.

   overscroll-behavior: contain, because the page behind is held still by
   useScrollLock and scroll chaining out of a list that has hit its end would
   otherwise fight that lock rather than simply stop.

   scrollbar-gutter: stable reserves the bar's width whether or not it is drawn,
   so a list that grows past the box does not shove its rows 15px left the
   moment it does. Ignored by browsers that do not know it, which lose nothing
   but the jump. */
.order-list {
  list-style: none;
  margin: 0 0 16px;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
  flex: 1 1 auto;
  /* THE FLOOR. The list is what gives way when the card runs short, and without
     a floor it gives way to nothing at all — a landscape phone would show the
     details, the "Order history" heading, and no orders under it. 7rem is two
     summary rows and the top of a third, which is enough for the list to read
     as a list and to be scrolled by hand. Below this the panel scrolls instead;
     see .account-dash. */
  min-height: 7rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;
  /* Momentum scrolling on the iOS versions that still need to be told. */
  -webkit-overflow-scrolling: touch;
}

/* A DRAWN SCROLLBAR, not an overlay one. macOS and iOS hide the bar until you
   are already scrolling, which is fine for a page — you know a page scrolls —
   and not fine for a list inside a dialog, where the bar is the only thing
   saying there are more orders below. Chrome and Safari take this and draw a
   permanent one; Firefox ignores it and keeps its own, which is always visible
   anyway. Sized narrow and inked in the border colour so it reads as part of
   the panel rather than as an operating system dropped into it.

   Touch is untouched by any of this: a flick scrolls the list on a phone
   whether a bar is painted or not, which is why nothing here uses
   `overflow: hidden` or a height that would take that away. */
.order-list::-webkit-scrollbar {
  width: 0.5rem;
}

.order-list::-webkit-scrollbar-track {
  background: transparent;
}

.order-list::-webkit-scrollbar-thumb {
  background: var(--border);
  border-radius: 999px;
}

@media (hover: hover) {
.order-list::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
}

.order-row {
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-soft);
  /* The trigger inside is a full-bleed button with its own radius; without this
     its focus ring and pressed background square off the row's corners. */
  overflow: hidden;
  /* A row must not be squeezed by the flex column it now sits in — a summary
     compressed to fit is how a list of forty pretends to be a list of four. */
  flex-shrink: 0;
}

/* THE ROW IS A BUTTON NOW (2026-08-29), and this is the button reset: the
   padding that used to be on .order-row, the row's own background rather than
   the UA's, and left-aligned inheriting text.

   THE GRID IS TWO COLUMNS — everything, then the chevron — rather than a flex
   row with three children. The summary inside is itself two stacked lines that
   must be free to wrap at 320px, and a grid track that is `minmax(0, 1fr)` lets
   them wrap without the chevron ever being the thing that gives way. That is
   the same failure <Disclosure /> hit and solved by making its chevron a
   flex-shrink: 0 sibling; this is the grid spelling of it. */
.order-trigger {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 12px 14px;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

@media (hover: hover) {
.order-trigger:hover { background: var(--card); }
}

/* Inside the button, not on it: an outline on the trigger would be clipped by
   the row's own `overflow: hidden`, and offsetting it outward would draw the
   ring over the neighbouring row. */
.order-trigger:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.order-summary {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}

.order-chevron {
  font-size: 0.7rem;
  color: var(--text-dim);
  transition: transform 0.2s ease;
}

.order-row.is-open .order-chevron { transform: rotate(180deg); }

/* No `display` of its own, for the reason spelled out on .disclosure-region:
   the `hidden` attribute's UA rule is display:none at the lowest possible
   specificity, and any class rule setting display here would beat it and leave
   the lines on screen with the row collapsed. */
.order-detail {
  padding: 0 14px 12px;
}

.order-detail[hidden] { display: none; }

.order-head {
  display: flex;
  align-items: center;
  /* WRAPS, because this line is four things at 320px inside a 440px card: the
     reference, the payment pill and the fulfilment badge. Left-aligned with a
     gap rather than space-between — space-between on a wrapped line strands the
     last item alone on the second row against the right edge. */
  flex-wrap: wrap;
  gap: 6px 8px;
}

.order-id {
  font-weight: 700;
  font-size: 0.9rem;
  line-height: 1.4;
  font-variant-numeric: tabular-nums;
}

/* The server's own status word. A pill rather than plain text so a failed order
   is distinguishable from a paid one at a glance rather than by reading, and
   keyed by class so the four states cannot drift into four hand-written
   colours. */
.order-status {
  padding: 0.125rem 0.5rem;
  border-radius: 999px;
  border: 1px solid var(--border);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
  /* Inert in the admin table, where this pill sits in a <td>. It matters in the
     account panel's summary line, which wraps: without it the pill is what
     gives way when the reference and the fulfilment badge want the room. */
  flex-shrink: 0;
}

.order-status.is-paid {
  border-color: var(--accent-2);
  color: var(--accent-2);
  background: rgba(10,122,104,0.08);
}

.order-status.is-failed,
.order-status.is-cancelled {
  border-color: var(--danger);
  color: var(--danger);
  background: rgba(192,57,43,0.06);
}

/* The second line of the summary: date left, total right. The 6px that used to
   be a margin here is .order-summary's gap now — keeping both stacked two
   spacings on one seam. */
.order-meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  color: var(--text-dim);
  font-size: 0.82rem;
}

.order-total {
  color: var(--text);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* The lines now live inside .order-detail, which supplies the row's side and
   bottom padding, so the margin this used to carry would be a second gap on top
   of the trigger's own. The dashed rule stays: it is what says the lines belong
   to the summary above rather than being a second row. */
.order-lines {
  list-style: none;
  margin: 0;
  padding: 10px 0 0;
  border-top: 1px dashed var(--border);
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 0.84rem;
}

.order-line-qty {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.order-line-pack { color: var(--text-dim); }

/* -- The fulfilment badge ---------------------------------------------------
   WHERE THE ORDER IS, beside the pill saying whether it has been paid for.
   Added 2026-08-29; before it the panel showed payment only, so an order the
   dispatch desk had marked Delivered still read PENDING to the person waiting
   for it.

   THE SAME THREE TOKENS THE DASHBOARD'S DROPDOWN USES — --fulfil-processing and
   its pair, and the other two. That is the point of the tokens and the reason
   no colour is written out longhand here: the two screens cannot disagree about
   what "shipped" looks like without one edit visibly changing both. The ink
   flips per state because the plates do not share a contrast: white on the
   sunset yellow is 1.61:1.

   SOLID, WHERE THE PAYMENT PILL IS OUTLINED. Two pills of the same shape side
   by side would be two facts a reader has to tell apart by reading them; a
   plate and an outline are told apart before that. It also matches the
   dashboard, where fulfilment is the coloured control and payment is flat text.
   No border, deliberately: .admin-select darkens one because a coloured plate
   inside a sage hairline reads as a swatch dropped onto a control, and a badge
   IS a swatch — there is no control for it to look dropped onto.

   There is no `is-pending` here because there is no pending fulfilment: 014's
   CHECK allows processing, shipped and delivered and defaults to the first, so
   an order is Processing from the moment it exists. */
.fulfil-badge {
  padding: 0.125rem 0.5rem;
  border-radius: 999px;
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  /* Beside a pill that may wrap; neither should be the one that squeezes. */
  flex-shrink: 0;
}

.fulfil-badge.is-processing {
  background: var(--fulfil-processing);
  color: var(--fulfil-processing-ink);
}

.fulfil-badge.is-shipped {
  background: var(--fulfil-shipped);
  color: var(--fulfil-shipped-ink);
}

.fulfil-badge.is-delivered {
  background: var(--fulfil-delivered);
  color: var(--fulfil-delivered-ink);
}

/* The navbar's account button. The name used to be a dead <span> beside a
   "Sign out" button, so the one obvious thing to click did nothing; it is the
   control now — and as of 2026-08-30 it is a round plate of INITIALS rather
   than a truncated name. See .nav-account-initials for why.

   The three overrides are what turn a .btn into that plate: the shared button
   carries horizontal padding and a 9px radius, and a circle needs neither. The
   width and height are stated on the BUTTON rather than left to the span, so
   the plate is round before its content arrives — an avatar that is a pill for
   one frame and a circle the next is a visible pop on every page load. */
.nav-account-btn {
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border-radius: 50%;
  flex-shrink: 0;
  overflow: hidden;
}

/* THE PLATE, COLOURED BY THE ACCOUNT'S GENDER. Asked for on 2026-09-14 and
   applied by genderPlateClass() in app.jsx, which writes one of these two
   classes onto the button — or neither, for an account with no gender, which is
   every account registered before that date and which keeps .btn's cream plate.
   The tokens, the measured ratios and the argument for the hues are in :root;
   see --gender-female.

   WRITTEN AS .btn.nav-account-btn-* — (0,2,0), deliberately. The modifier alone
   would be (0,1,0), exactly .btn's own specificity, so it would win only by
   sitting later in the file and would silently lose the day somebody moved it
   or added a .btn rule below. This file already documents that trap twice
   (.nav-search-go, and .nav-actions .btn inside the 479px block).

   THE RING IS THE POINT, not a flourish. .btn's --border hairline measures
   2.24:1 on the sage band and was carried by the cream fill behind it, which
   reaches 3.01:1; a pink or sunset fill is 1.78:1 and 1.90:1, so the plate's
   own edge falls under WCAG 1.4.11's 3:1 for a component boundary. --brand-ink
   measures 5.42:1 on that band and puts the edge back with room to spare. It is
   the ink already sitting on both plates, so the ring reads as part of the
   badge rather than as an outline added to it.

   `color` is set for the same pairing reason the --fulfil-* tokens keep: the
   ink travels with its plate. Both resolve to --brand-ink today, and they are
   still written per plate so changing one hue cannot silently leave the other's
   ink behind. */
.btn.nav-account-btn-female {
  background: var(--gender-female);
  border-color: var(--brand-ink);
  color: var(--gender-female-ink);
}

.btn.nav-account-btn-male {
  background: var(--gender-male);
  border-color: var(--brand-ink);
  color: var(--gender-male-ink);
}

/* NO :hover OVERRIDE, and it was written and then deleted rather than never
   considered. `.btn:hover` swaps the ring to --accent, which measures 1.59:1 on
   the sage band against the resting ring's 5.42:1 — so hovering a coloured
   plate visibly SOFTENS its edge, which is the opposite of what a hover state
   should do, and pinning the ring dark fixes that number.

   It is still wrong to pin it. Border-colour IS the hover signal on every
   button in this file, and .nav-account-btn declares no other one, so holding
   the ring would leave this control the only button on the bar that does not
   respond to the pointer at all — a worse fault than a transient ratio, and one
   a customer actually notices. The resting state is what 1.4.11 is measured on,
   and that is 5.42:1 now against the 3.01:1 the cream plate managed. The
   existing cream plate already goes to the same 1.59:1 ring on hover, so this
   is consistency, not a new regression. */

/* ==== BEGIN: Admin dashboards ============================================== */
/* The admin dashboards inside the storefront (#/admin) — seven of them now, not
   the three this line counted for a long time. Built entirely from the existing
   tokens — --card, --border, --bg-soft, --accent-2, --danger, --radius — and the
   existing type scale, because the brief asked for the site's own design and an
   admin area in a second visual language is two sites to maintain and two to
   relearn.

   THE CONSOLE HAS ITS OWN GROUND SINCE 2026-09-21, and that is a change to the
   paragraph above rather than an exception to it. Three tokens were added
   (--admin-ground, --admin-head-row, --admin-stripe, all at the foot of the
   token block) and the ground itself is painted on .body-wrap.is-admin, up in
   the layout section where the bands live. They are the same sage the navbar and
   footer already use, taken most of the way to white; nothing about the type,
   the radii or the panel surfaces moved. The console reads as a different room
   in the same building, which is what "an admin area in a second visual
   language" was warning against the opposite of.

   Nothing here restyles a shared class, with four long-standing exceptions that
   are all narrowing rather than restyling — .admin-confirm-actions .btn,
   .field-select.admin-select, .bulk-body .field and .bulk-map-field
   .field-select, each of which sets width or margin for a shared control inside
   an admin container and none of which changes how one looks anywhere else.
   .btn itself (19 of them in here, all through the shared <Button />),
   .modal-card under the three confirm dialogs, .field-select, .order-status,
   .cart-empty and .auth-failure are reused exactly as they stand — which is why
   this block is still layout, spacing and ground almost throughout. */

/* THE SIDE GUTTER IS BACK, and the shorthand is why it was ever gone. This rule
   said `padding: 32px 0 80px`, which reads as "vertical only" and is not — a
   shorthand writes all four sides, so the 0 overrode the `0 var(--page-pad)`
   that .container sets up in the Layout section. Same specificity, this rule
   later, horizontal padding zero. Every panel in the console has been flush to
   the viewport edge below 1160px ever since, which on cream was invisible
   because a --card panel against --bg is 1.07:1 and there was no edge to see.
   On --admin-ground it is 1.19:1 and a white panel running off the side of the
   screen reads as a broken layout.

   IT COSTS THE TABLES 2 x --page-pad, and that is worth naming because the
   .container.admin-wrap rule at the top of this file exists to stop exactly that
   kind of loss. What it protects is the CAP — that the console is never boxed
   into a measure narrower than the catalogue's — and the gutter applies equally
   to both, so the guarantee is untouched: admin content is now the same width as
   catalogue content at the same cap rather than --page-pad wider. Nothing
   clips either. The eight-column tables already scroll inside
   .admin-table-scroll, which is the wrapper that exists so the table moves and
   the page does not. */
.admin-wrap {
  padding: 32px var(--page-pad) 80px;
}

/* The masthead. It sits directly on --admin-ground rather than in a panel of its
   own: a card here would be a fourth surface between the navbar and the first
   real dashboard, and the ground is what this screen has instead.

   THE RULE UNDER IT IS THE SAGE ITSELF, not --border. A hairline in --border
   (#d7dfd2) against --admin-ground computes 1.13:1 and is invisible on this
   ground — it was chosen for cream. --surface-band is 2.53:1 against it, which
   is not the 3:1 a meaningful graphic would need, and it does not have to be:
   the heading above and the tab strip below both carry the separation on their
   own, so this line is decoration doing a job of emphasis, not the boundary. */
.admin-head {
  margin-bottom: 22px;
  padding-bottom: 16px;
  border-bottom: 2px solid var(--surface-band);
}

.admin-head h1 {
  margin: 0 0 6px;
  font-size: 1.75rem;
  line-height: 1.15;
}

.admin-head p {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.9rem;
  line-height: 1.5;
}

/* The refusal a non-admin sees. Centred and short: it is not an error the
   reader can act on, so it says what the area is and offers the way out
   rather than explaining itself. */
.admin-denied {
  max-width: 34rem;
  margin: 48px auto;
  padding: 32px 28px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  box-shadow: var(--shadow);
  text-align: center;
}

.admin-denied h1 { margin: 0 0 10px; font-size: 1.3rem; }

.admin-denied p {
  margin: 0 0 14px;
  color: var(--text-dim);
  font-size: 0.9rem;
  line-height: 1.55;
}

.admin-denied p:last-child { margin-bottom: 0; }

/* -- Tabs ------------------------------------------------------------------- */
/* The same pill vocabulary as .choice and .contact-tabs, so a third row of
   selectable things does not read as a third idea.

   THE STRIP CARRIES THE EDGE NOW, AND THE PILLS DO NOT. Seven outlined pills
   used to float straight on the page; on --admin-ground each one's --border
   hairline computes 1.13:1 against the ground and stops delimiting anything, so
   seven separate boundaries became one. Putting the border and the --card fill
   on the container instead is also what the strip already meant — these are
   seven views of one console, not seven unrelated buttons.

   width: fit-content so the strip ends where the tabs end. It is a control, and
   a control stretched to 1160px of empty --card would read as a panel. It still
   wraps: max-width holds it inside the column, and the radius is --radius rather
   than 999px precisely because a wrapped two-line strip with round ends looks
   broken while a rounded rectangle does not. */

.admin-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  width: fit-content;
  max-width: 100%;
  margin-bottom: 20px;
  padding: 6px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  box-shadow: var(--shadow);
}

.admin-tab {
  padding: 0.5rem 0.9375rem;
  border-radius: 999px;
  /* Transparent rather than absent, so selecting a tab cannot resize the strip
     by the two pixels its border adds. */
  border: 1px solid transparent;
  background: none;
  color: var(--text-dim);
  font-size: 0.86rem;
  font-weight: 600;
  transition: all 0.15s ease;
}

/* A ground rather than an outline, which is the only signal available once the
   pills have no border of their own to recolour. --text-dim to --text at the
   same time, so the hover reads on a touch of a trackpad as well as by fill. */
@media (hover: hover) {
  .admin-tab:hover {
    background: var(--bg-soft);
    color: var(--text);
  }
}

/* AND A RESTING FILL WHERE THERE IS NO POINTER TO HOVER WITH. A touchscreen
   matches `hover: none`, so the block above never applies there and the six
   unselected pills would render as bare dim text on the strip — no fill, no
   border, and no way to reveal one. The selected tab would be the only thing on
   the strip that looked pressable, which is exactly backwards.

   It is --bg-soft, the same fill the pointer gets, because the two are the same
   statement: "this is a control". This console is already treated as a touch
   surface — the coarse-pointer block in the responsive section gives .admin-tab
   a 44px minimum — and that is the device this covers. */
@media (hover: none) {
  .admin-tab:not(.is-active) { background: var(--bg-soft); }
}

.admin-tab:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* THE SELECTED TAB IS THE BRAND BAND, matching the navbar directly above it and
   the invoice the console issues. --brand-ink #14220f on --surface-band #6f9f6f
   computes 5.42:1 by the WCAG relative-luminance formula, which clears AA for
   the 0.86rem semibold it carries — better than the 4.84:1 plain --text manages
   on the same fill — the figure .btn-sage's own note derives, under "THE LABEL
   IS DARK INK, MEASURED" in the Buttons section — and the reason the ink here is
   --brand-ink rather than --text. White is not available on this band; it
   measures 3.06:1. (Cited by the rule rather than by a line number on purpose:
   this change alone moved that note down 68 lines.) */
.admin-tab.is-active {
  background: var(--surface-band);
  border-color: var(--surface-band);
  color: var(--brand-ink);
}

/* A GUARD, NOT A FIX, and the difference is worth writing down because the
   obvious reading of this rule is wrong. .admin-tab:hover and .admin-tab.is-active
   are both (0,2,0) and is-active is declared later, so the selected tab already
   holds its band under the pointer with no help — deleting this block changes no
   computed value in any state, measured. What it buys is that the pair cannot be
   broken by moving a rule: at (0,3,0) this wins on specificity rather than on
   source order, and the one pill that must never look unselected stops depending
   on which of two equal-weight rules happens to come second. */
@media (hover: hover) {
  .admin-tab.is-active:hover {
    background: var(--surface-band);
    color: var(--brand-ink);
  }
}

/* The selected dashboard. It carries no box of its own — each panel inside
   draws its own card — so this exists only to give the tabpanel a top margin
   the tabs above it do not have to carry as a bottom margin. Written out rather
   than folded into .admin-tabs because a margin belongs to the thing being
   pushed away, not to the thing pushing. */
.admin-panel { margin-top: 4px; }

/* -- Metrics row ------------------------------------------------------------ */
/* Three cards, equal width, wrapping to one column on a phone. auto-fit with a
   17rem floor rather than a breakpoint, for the same reason .choice-group uses
   one: three cards that each need about 17rem either fit or they do not, and
   the viewport width at which that happens depends on the container, not on a
   number somebody guessed. */

.metric-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
  gap: 14px;
  margin-bottom: 24px;
}

.metric-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 18px 20px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  box-shadow: var(--shadow);
}

.metric-label {
  font-size: 0.74rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* Tabular, because these three sit in a row and a proportional 1 makes two
   figures of the same magnitude look different lengths. */
.metric-value {
  font-size: 1.9rem;
  font-weight: 800;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

.metric-note {
  font-size: 0.78rem;
  line-height: 1.45;
  color: var(--text-dim);
}

/* The low-stock card when something actually needs attention. A left rule and a
   tinted ground rather than red numerals: the figure is still a figure, and
   colouring the number itself would make a count of 4 look like an error state
   rather than a quantity. */
.metric-card.is-alert {
  border-color: var(--danger);
  border-left-width: 3px;
  background: rgba(192,57,43,0.05);
}

.metric-card.is-alert .metric-label { color: var(--danger); }


/* -- The dashboard blocks --------------------------------------------------- */
/* admin-split IS GONE (2026-08-23) and is not coming back. It was the grid that
   held the order table at 1.6fr beside the stock panel at 1fr, stacking below
   1100px. Retiring the duplicate stock panel left it with a single child on
   every tab, and a one-column grid is not a layout — every admin block is full
   width now. If a second panel ever needs to sit beside one, write that grid
   then; this one carried a media query for a case that no longer exists.

   Written without a leading dot, per the note at btn-gold above: the class-name
   audit reads a dotted name in a comment as a live selector. */

.admin-block {
  min-width: 0;
  padding: 20px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  box-shadow: var(--shadow);
}

/* The stock panel and the accounts table when each is a whole dashboard rather
   than half of one. Nothing to set but the reminder that it is deliberate —
   the grid above is what constrains it, and on its own tab there is no grid. */
.admin-block.is-wide { width: 100%; }

/* The sage tick is the one mark that ties a panel to the console around it.
   A border on the element rather than a ::before box: this heading is a block on
   one line, so its own left edge IS the tick, at exactly the line's height and
   with nothing to keep in step as the type scale moves.

   NOT A STATUS COLOUR, and that is why it is --surface-band and not --accent.
   Every coloured mark inside these panels already means something — --danger is
   destructive or failed, --accent-2 is unread, --accent is the primary action,
   the three --fulfil-* plates are order states. The sage is the site's own
   furniture and is the only colour on this screen that claims nothing. */
.admin-block-head {
  margin: 0 0 14px;
  padding-left: 10px;
  border-left: 3px solid var(--surface-band);
  font-size: 0.78rem;
  font-weight: 700;
  line-height: 1.35;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* A block heading with a control on the right of it.

   The heading keeps its own margin-bottom and this row carries none, so a block
   with a button in its head sits on exactly the same rhythm as one without —
   the alternative is two spacings that drift the moment either is touched.

   wrap, and align-items: center rather than baseline: the heading is 0.78rem
   uppercase and the button is a full control, so a shared baseline hangs the
   small type off the bottom of the tall one. Centred reads as one row. On a
   narrow admin screen the button drops under the heading, where the gap
   supplies the space. */
.admin-head-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 14px;
}

.admin-head-row .admin-block-head { margin: 0; }

/* Never the full width of the block, whatever .btn-block might be doing
   elsewhere, and small enough not to compete with the table under it. */
.admin-export {
  flex: 0 0 auto;
  padding: 0.4375rem 0.875rem;
  font-size: 0.8rem;
}

/* What the export did, under the button that did it. Dim and small — it is a
   receipt, not a result. */
.admin-export-note {
  margin: 0 0 12px;
  color: var(--text-dim);
  font-size: 0.78rem;
  line-height: 1.5;
}

/* -- Deleting an order ------------------------------------------------------ */

/* The per-row Delete. QUIET UNTIL IT IS POINTED AT: a column of red buttons
   down a table of orders makes the destructive action the loudest thing on the
   screen, and it is the one that should be hardest to press by accident. It
   carries the danger ink only on hover and focus, which is the moment it is
   about to be used.

   It is not a .btn: those have a presence this deliberately lacks.

   THE HIT AREA IS LARGER THAN THE INK, which is the whole trick here. Quiet
   does not have to mean small: an admin row has the vertical room, so the
   padding and min-height buy an easier target without adding any presence at
   rest. The colour still arrives only on hover and focus. */
.admin-delete {
  padding: 0.4rem 0.65rem;
  min-height: 32px;
  border: 1px solid transparent;
  border-radius: 7px;
  background: none;
  color: var(--text-dim);
  font: inherit;
  font-size: 0.78rem;
  font-weight: 600;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}

@media (hover: hover) {
  .admin-delete:hover {
    color: var(--danger);
    border-color: var(--danger);
  }
}

/* NOT YET EXPORTED. The delete is offered only once a workbook has been taken
   of the order, because that file is the copy which outlives the row — and this
   is what the shut state looks like.

   aria-disabled AND NOT :disabled, the same mechanism .btn[aria-disabled] uses
   for "Pay on delivery" on the storefront: a real `disabled` attribute swallows
   the click, and the press is the only thing that produces the sentence saying
   WHY the button is shut. So it stays pressable, drops to 0.45, and takes
   `cursor: not-allowed` so the pointer says what the colour does.

   0.45 AND NOT THE 0.5 THE BUTTONS USE. This control starts at --text-dim on
   the card rather than at --text, so the same wash leaves it fainter than the
   dimmed buttons elsewhere; the extra 0.05 of ink is what keeps the word
   legible at 0.78rem. It is an inactive control either way, which 1.4.3
   exempts, and the reason it is pressable at all is that it explains itself. */
.admin-delete[aria-disabled="true"] {
  opacity: 0.45;
  cursor: not-allowed;
}

/* The hover would otherwise recolour a shut control to --danger and outline it,
   which reads as armed. */
@media (hover: hover) {
  .admin-delete[aria-disabled="true"]:hover {
    color: var(--text-dim);
    border-color: transparent;
  }
}

.admin-delete:focus-visible {
  outline: 2px solid var(--danger);
  outline-offset: 1px;
  color: var(--danger);
}

/* The confirmation, inside a <Modal />.

   THE WRAPPER CARRIES THE PADDING, because .modal-card has none. That card
   supplies the surface, the border and the shadow, and every panel placed in it
   pads its own content — .cart-panel, .auth-card, .contact-panel and .legal-doc
   all do. .admin-confirm was the one that did not, so the heading, the warning
   panel and the facts list all sat 1px from the card's edge (that 1px being the
   border). Measured at 412px before this rule: .admin-confirm-warn's danger bar
   was flush against the card's rounded corner, and the heading ran to within a
   few pixels of the close button.

   Mirrors .cart-panel's 24px 22px 26px rather than inventing a value: same
   card, same left-aligned h2, same absolutely-positioned close.

   24px of top padding does NOT clear .modal-close and is not meant to — that
   button is top: 12px and 1.875rem tall, so it occupies 12-42px. It sits at
   right: 12px while this heading is short and left-aligned, which is the
   arrangement .cart-panel already relies on. A heading long enough to reach it
   would need the 44-46px top that .contact-panel and .auth-card take instead. */
.admin-confirm {
  padding: 24px 22px 26px;
}

.admin-confirm h2 {
  margin: 0 0 10px;
  font-size: 1.15rem;
}

/* THE WARNING ITSELF, and it is styled to be read rather than skimmed past: a
   danger-tinted panel with a bar down its left edge — the "this is why you
   cannot" language .product-inactive-note used to share, and which this rule
   alone carries now that it has been retired. Not a bare red sentence — red text on cream at 0.86rem is easy to slide
   over, and this is the one paragraph on the screen that must not be.

   THE INK STAYS --text, and that is deliberate rather than lazy. The panel is
   the emphasis; setting the words in --danger as well would be the same signal
   twice and would trade a 12.92:1 paragraph for a 4.75:1 one. Measured: the 8%
   tint composites to #faede7 over --card, --text on it is 12.92:1, and the
   3px bar in --danger is 4.75:1 against the same ground — comfortably over the
   3:1 that a non-text indicator needs to be seen. */
.admin-confirm-warn {
  margin: 0 0 18px;
  padding: 14px 16px;
  border-left: 3px solid var(--danger);
  border-radius: 0 8px 8px 0;
  background: rgba(190, 52, 40, 0.08);
  color: var(--text);
  font-size: 0.86rem;
  line-height: 1.55;
}

/* The four facts that identify the row. Two columns, labels dim, values plain
   — the same shape the cart's totals use. */
.admin-confirm-facts {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 14px;
  margin: 0 0 18px;
  font-size: 0.86rem;
}

.admin-confirm-facts dt {
  color: var(--text-dim);
  font-weight: 600;
}

.admin-confirm-facts dd {
  margin: 0;
  text-align: right;
}

/* STACKED, EACH BUTTON THE FULL WIDTH OF THE PANEL, on the user's instruction
   (2026-09-03). Was a right-aligned flex row of auto-width buttons.

   THIS GAVE UP A DELIBERATE SAFETY PROPERTY and the trade is recorded rather
   than quietly dropped. The row was right-aligned specifically so the
   destructive button sat furthest from a thumb resting on the left edge of a
   phone. A full-width button cannot be far from anything horizontally, and in
   DOM order "Delete order" is the lower of the two — so it is now the one
   nearest the thumb, not the furthest.

   What still guards it: the button says "Delete order" rather than "OK", the
   warning panel above states what is lost, and this dialog only opens from a
   deliberate press on .admin-delete. If the thumb reach matters more than the
   reading order, `flex-direction: column-reverse` puts "Delete order" on top
   and "Keep it" within easy reach — a one-word change, and the pattern iOS
   uses for exactly this reason.

   No justify-content and no flex-wrap: neither does anything once the items
   are full-width in a column. */
.admin-confirm-actions {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

/* width rather than `flex: 1`: these must be full width, not merely equal to
   each other, and .btn-block already establishes width: 100% as how this
   stylesheet says "fill the line". */
.admin-confirm-actions .btn { width: 100%; }

/* The unread count on the Messages tab. Sits ON the tab strip, which is the
   surface an administrator reads before choosing a tab — a badge inside the
   panel it points at is a badge nobody sees in time.

   Rendered only when there is something to say, so there is no zero state to
   style. --danger because it is the one thing on this strip asking to be acted
   on; white on it measures 5.44:1, which clears AA for the small bold text it
   carries.

   inline-flex with a min-width equal to its height so a single digit is a
   circle and a three-character "99+" grows into a pill rather than being
   clipped. `vertical-align` nudges it off the text baseline, which is what
   stops it dragging the tab's own line box taller and reflowing the strip. */
.admin-tab-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.125rem;
  height: 1.125rem;
  margin-left: 0.375rem;
  padding: 0 0.3125rem;
  border-radius: 999px;
  background: var(--danger);
  color: #fff;
  font-size: 0.62rem;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0;
  text-transform: none;
  vertical-align: 1px;
}

/* -- The messages inbox ----------------------------------------------------- */

/* A count beside the heading, not a badge on the tab. The tab is one of four
   and a red dot on it says only "something happened somewhere"; here it says
   how many and is beside the list it refers to. Inline-block so the uppercase
   letter-spacing of .admin-block-head does not stretch it. */
.admin-unread {
  display: inline-block;
  margin-left: 8px;
  padding: 0.125rem 0.4375rem;
  border-radius: 999px;
  background: var(--danger);
  color: #fff;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: none;
  vertical-align: 1px;
}

/* CARDS, NOT ROWS — see <AdminMessagesDashboard /> for why a message is not
   tabular. A plain list, unbulleted, with the gap doing the separating. */
.admin-messages {
  list-style: none;
  margin: 0 0 14px;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.admin-message {
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: 11px;
  background: var(--card);
}

/* UNREAD IS A LEFT EDGE, not a background wash. A tinted card makes the text on
   it harder to read, which is the wrong trade on the one card somebody most
   needs to read; a bar in the accent marks it just as clearly and costs the
   prose nothing. The padding is reduced by the bar's width so the text does not
   step sideways as a message is marked read. */
.admin-message.is-new {
  border-left: 3px solid var(--accent-2);
  padding-left: 14px;
}

.admin-message-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 10px;
}

/* The channel and the delivery outcome, as two chips. Both are small caps on a
   soft ground — the same language .product-class uses — so they read as labels
   rather than as buttons somebody should press. */
.message-channel,
.message-delivery {
  padding: 0.125rem 0.5rem;
  border-radius: 999px;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
}

.message-channel {
  border: 1px solid var(--border);
  background: var(--bg-soft);
  color: var(--text-dim);
}

/* THE DELIVERY CHIP IS THE ONE THAT CARRIES COLOUR, because it is the only one
   with anything to warn about. "Emailed" and "In app" are both fine and both
   quiet; "Not emailed" is the state an operator has to act on and is the only
   one in the danger ink. Every ink here is measured against its own ground, not
   against the page: on white, --accent-2 is 4.83:1 and --danger 5.44:1. */
.message-delivery.is-sent {
  background: rgba(10,122,104,0.12);
  color: var(--accent-2);
}

.message-delivery.is-handoff {
  background: var(--bg-soft);
  color: var(--text-dim);
}

.message-delivery.is-pending {
  background: var(--bg-soft);
  color: var(--text-dim);
}

.message-delivery.is-failed {
  background: rgba(192,57,43,0.10);
  color: var(--danger);
}

/* Why the email did not go, in the server's own words. Dim and small: it is for
   whoever runs the server, not for whoever answers the message. */
.message-delivery-note {
  margin: 0 0 10px;
  padding: 8px 10px;
  border-left: 3px solid var(--danger);
  border-radius: 0 7px 7px 0;
  background: rgba(192,57,43,0.06);
  color: var(--text-dim);
  font-size: 0.75rem;
  line-height: 1.5;
}

/* Who wrote it. Two columns, labels dim — the same shape .admin-confirm-facts
   uses, left-aligned here because these are facts to read rather than figures
   to compare. */
.admin-message-from {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 2px 14px;
  margin: 0 0 12px;
  font-size: 0.8rem;
}

.admin-message-from dt {
  color: var(--text-dim);
  font-weight: 600;
}

.admin-message-from dd { margin: 0; }

/* THE MESSAGE ITSELF, and the two declarations that matter are the last two.
   pre-wrap keeps the line breaks the customer typed — a numbered list of five
   products is a list, not one paragraph — while still wrapping long lines,
   which `pre` alone would not. overflow-wrap breaks a single unbroken string:
   a pasted 200-character product code or a URL would otherwise push the card
   past the width of the panel and put a horizontal scrollbar on the page. */
.admin-message-body {
  margin: 0 0 12px;
  padding: 10px 12px;
  border-radius: 9px;
  background: var(--bg-soft);
  color: var(--text);
  font-size: 0.88rem;
  line-height: 1.6;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.admin-message-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
}

/* mailto: and wa.me. Underlined, because they are links that LEAVE this page —
   and on a panel where every other control stays put, that is worth signalling
   in the one way a reader already recognises. */
.message-reply {
  color: var(--accent-2);
  font-size: 0.78rem;
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 2px;
}

@media (hover: hover) {
  .message-reply:hover { color: var(--accent); }
}

/* The delete pushed to the far end, away from the two replies. Three controls
   in a row where the third destroys something and the first two are routine is
   exactly the arrangement that earns a gap. */
.admin-message-actions .admin-delete { margin-left: auto; }

/* -- Search and filter rows ------------------------------------------------- */

.admin-filter {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

/* The caption on a filter that is a SELECT rather than a search box — the
   Registrations tab's state picker. A search input carries its own placeholder
   and needs no visible label; a select has no such affordance, and "Blocked"
   sitting alone above a table is a word with no question attached to it. */
.admin-filter-label {
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--text-dim);
  letter-spacing: 0.02em;
}

/* Takes the remaining width, with a floor that keeps it usable when the panel
   is the narrow half of the split. */
.admin-filter input[type="search"] {
  flex: 1 1 11rem;
  min-width: 0;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: 0.625rem;
  background: var(--bg-soft);
  color: var(--text);
  font-family: inherit;
  font-size: 0.86rem;
}

.admin-filter-check {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--text-dim);
  font-size: 0.82rem;
  white-space: nowrap;
}

.admin-filter-check input { accent-color: var(--accent); }

/* -- Tables ----------------------------------------------------------------- */
/* A real <table>, and the scroll wrapper is not decoration: seven columns of
   customer data do not fit half a laptop, and without this the PAGE scrolls
   sideways instead of the table. Same construction as .legal-table-scroll. */

.admin-table-scroll {
  overflow-x: auto;
  margin-bottom: 14px;
}

.admin-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.84rem;
}

.admin-table th,
.admin-table td {
  padding: 0.5rem 0.625rem;
  text-align: left;
  vertical-align: top;
  border-bottom: 1px solid var(--border);
}

.admin-table th {
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-dim);
  white-space: nowrap;
}

/* A GROUND AND A HEAVIER RULE UNDER THE HEADINGS. These tables run to eight
   columns and, in the stock and order panels, well past one screen of rows; a
   heading row set only in small caps is a row like any other once the reader has
   scrolled. --text-dim on --admin-thead computes 5.01:1, against 5.59:1 on
   bare --card — a real cost, stated, and still clear of AA.

   thead th rather than th: .admin-message-from and the confirm dialog's <dl>
   are not tables, but the batch rows inside the listing form are, and a bare th
   would paint their inline headings too. */
.admin-table thead th {
  background: var(--admin-thead);
  border-bottom: 2px solid var(--border);
}

/* ALTERNATING ROWS, and they earn their keep on exactly the tables that made
   .admin-table-scroll necessary: eight columns wide enough to scroll sideways is
   eight columns wide enough to lose your line on. Composited over --card the
   stripe is L 0.942, so --text-dim on it is 5.37:1 and nothing dims.

   DECLARED BEFORE THE HOVER RULE BELOW, and that ordering is the whole rule.
   Both selectors compute to the same specificity, so the later one wins; written
   after, the stripe would repaint every even row from under the pointer and the
   console would have no hover state on half its rows. */
.admin-table tbody tr:nth-child(even) { background: var(--admin-stripe); }

/* A STRIPE MUST NEVER OUTRANK A ROW THAT MEANS SOMETHING, and it nearly did.
   .bulk-row-bad — the bulk upload's "this row would not parse" tint, declared
   under its own banner at the foot of this block — is a bare (0,1,0) class on a
   <tr> that lives inside .admin-table, while the stripe above is (0,2,2). A
   higher specificity beats any source order, so the stripe silently repainted
   every EVEN refused row: half of a failed upload lost its red, alternating, so
   a wholly mis-mapped column read as an ordinary striped table rather than as
   forty rejected rows. Caught in review before this shipped, by looking for
   exactly this — the only other <tr> in app.jsx that carries a class.

   BOTH RULES ARE RESTATED HERE, and they have to be, because the specificity
   ladder only works if every rung is on it. The tint goes to (0,3,2) so it beats
   the stripe, and the hover goes to (0,4,2) for the tint so a refused row still
   answers the pointer exactly as it did before the stripe existed. Dropping the
   second would have been a quieter version of the same bug. */
.admin-table tbody tr.bulk-row-bad { background: var(--admin-row-bad); }

@media (hover: hover) {
  .admin-table tbody tr:hover,
  .admin-table tbody tr.bulk-row-bad:hover { background: var(--bg-soft); }
}

/* Money and counts right-aligned and tabular, so a column of figures can be
   compared down its own edge. */
.admin-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.admin-table .admin-num { text-align: right; }

/* The second line in a cell — a product id under a brand, a username under a
   name. Block so it wraps under rather than beside. */
.admin-sub {
  display: block;
  margin-top: 2px;
  color: var(--text-dim);
  font-size: 0.74rem;
  line-height: 1.45;
  overflow-wrap: anywhere;
}

/* -- The fulfilment dropdown ------------------------------------------------ */
/* The status control in the order table, colour-coded by state.

   IT WAS A NATIVE SELECT UNTIL 2026-08-23, and not on purpose. The base field
   skin is scoped `.field input, .field textarea, .field select`, and this
   control is inside a table cell rather than a .field — so it matched NOTHING
   but the three declarations below and rendered with the OS appearance:
   background #fff, ink #000, a grey 1px border, and its own arrow. Measured in
   the browser, not inferred. That also meant the field-select ::after chevron
   was drawn on top of the native arrow, so the cell carried TWO. Taking control
   of the appearance is what the colour coding needed anyway.

   THE STATE CLASS IS ON THE WRAPPER, not the select, because the chevron is the
   wrapper's pseudo-element and has to change ink with the plate behind it.
   text-dim on #c0392b is 2.4:1 and vanishes. */

/* BOTH CLASSES, deliberately, and this is the whole bug of 2026-08-29. The
   wrapper carries `field-select admin-select`; `.field-select { display: block }`
   is declared 700 lines FURTHER DOWN this file at equal specificity, so it won
   the cascade and the wrapper stretched to the width of the table cell. The
   chevron is that wrapper's ::after at `right: 13px`, so it was drawn 256px to
   the RIGHT of the control it belongs to — measured at 2560px, where the
   Fulfilment column is 418px wide — and in the plate's ink, which is white, on
   the card behind it. White on cream. The arrow was invisible and the select
   read as a static coloured badge: the dropdown looked deleted.

   It only showed on a wide monitor. The displacement is (cell width − select
   width), so at 1366px the arrow was a few pixels off and looked merely loose.

   `.field-select.admin-select` is 0,2,0 and wins wherever it sits in the file,
   which is the point: fixing this by MOVING the rule below .field-select would
   leave the same trap set for the next edit that reorders a block. */
.field-select.admin-select { display: inline-block; }

.admin-select select {
  appearance: none;
  -webkit-appearance: none;
  padding: 0.3125rem 1.75rem 0.3125rem 0.5rem;
  font-size: 0.8rem;
  font-family: inherit;
  font-weight: 600;
  border-radius: 0.5rem;
  border: 1px solid var(--border);
  background: var(--bg-soft);
  color: var(--text);
  cursor: pointer;
  outline: none;
}

.admin-select select:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* A row mid-write is disabled; saying so costs one rule and stops the plate
   reading as a live control that is ignoring the pointer. */
.admin-select select:disabled { opacity: 0.65; cursor: progress; }

/* THE THREE STATES. Plate and ink together, always — see the fulfil- tokens for
   the measured contrast and why one ink for all three does not work.

   The border is darkened rather than left sage: a coloured plate inside a
   var(--border) hairline reads as a swatch that has been dropped on the control
   instead of as the control. currentColor would be the ink, which is wrong on
   the light plate, so each state names its own. */
.admin-select.is-processing select {
  background: var(--fulfil-processing);
  color: var(--fulfil-processing-ink);
  border-color: #93291e;
}

.admin-select.is-shipped select {
  background: var(--fulfil-shipped);
  color: var(--fulfil-shipped-ink);
  border-color: #d99320;
}

.admin-select.is-delivered select {
  background: var(--fulfil-delivered);
  color: var(--fulfil-delivered-ink);
  border-color: #1f5637;
}

/* The chevron follows the ink. Without these three the arrow stays text-dim and
   disappears into the red and the green. */
.admin-select.is-processing::after { color: var(--fulfil-processing-ink); }
.admin-select.is-shipped::after { color: var(--fulfil-shipped-ink); }
.admin-select.is-delivered::after { color: var(--fulfil-delivered-ink); }

/* THE OPEN LIST IS THE OPERATING SYSTEM'S, not ours. A coloured plate on the
   closed control is the whole of what CSS can promise here; the popup drawn on
   click is the platform's and ignores most of this.

   The options are given an explicit neutral pair rather than left to inherit,
   because inheritance is the one thing that would be actively wrong: an option
   taking the state plate AND the state ink is legible, but an option taking one
   and not the other is not, and which of those you get is per-platform. Naming
   both removes the question. Not verified on every browser — it cannot be, which
   is the reason to state the intent here rather than a result. */
.admin-select select option {
  background: var(--card);
  color: var(--text);
  font-weight: 500;
}

/* -- The pager -------------------------------------------------------------- */
/* Numbered pages under each admin table, replacing the "Show more" button that
   appended (2026-08-23). See <AdminPager /> for why, and for the windowing that
   keeps this control the same width at page 2 and at page 62.

   A ROW THAT WRAPS RATHER THAN SCROLLS. The count line and the buttons are one
   flex row on a desktop and two on a phone, because the alternative at 375px is
   a horizontally scrolling pager — a control you have to scroll to reach the
   control. space-between rather than a gap so the count sits with the table it
   describes and the buttons stay under the thumb on the right. */

.admin-pager {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

.admin-pager-count {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.78rem;
  font-variant-numeric: tabular-nums;
}

.admin-pager-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}

.admin-page-btn {
  min-width: 2rem;
  padding: 0.3125rem 0.5rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  background: var(--card);
  color: var(--text);
  font-family: inherit;
  font-size: 0.78rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease;
}

/* Tabular figures so the row does not twitch as the digits change — a 1 and a
   4 are different widths in this face, and a pager is a fixed row of numbers
   that should not reflow when you use it. */
.admin-page-btn.is-num { font-variant-numeric: tabular-nums; }

@media (hover: hover) {
  .admin-page-btn:not(:disabled):hover {
    border-color: var(--accent);
    background: var(--bg-soft);
  }
}

.admin-page-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* THE CURRENT PAGE IS NOT A LINK YOU PRESS AGAIN, but it stays enabled: a
   disabled button leaves the focus ring nowhere to sit when you arrive by
   keyboard, and pressing it is a harmless refetch. aria-current carries the
   meaning; this carries the look. */
.admin-page-btn.is-current {
  border-color: var(--accent);
  background: var(--accent);
  color: #fff;
  cursor: default;
}

.admin-page-btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* aria-hidden in the markup, so this is decoration only. Padding rather than
   width: an ellipsis is narrower than a page button and forcing it to match
   would put a gap either side of it. */
.admin-pager-gap {
  padding: 0 2px;
  color: var(--text-dim);
  font-size: 0.78rem;
}

/* TOUCH TARGETS. Measured at 28x32px, which is comfortable with a mouse and
   under the 44px a thumb wants. Grown on a COARSE POINTER only rather than for
   everyone: the desktop pager is a dense row by design and sits under a table
   whose rows are the same height. */
@media (pointer: coarse) {
  .admin-page-btn {
    min-height: 2.75rem;
    min-width: 2.75rem;
  }
}

/* -- Stock list ------------------------------------------------------------- */
/* A list of editable cards rather than a table, and that is a considered
   difference from the order table on the neighbouring tab. Each row here holds
   THREE text inputs and a button; in a table those become four columns that
   cannot shrink, and the whole thing scrolls sideways on any screen. A card
   wraps its own fields instead. */

.stock-list {
  list-style: none;
  margin: 0 0 14px;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.stock-row {
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-soft);
}

/* The two states worth seeing from across the list. Out of stock outranks low
   — it is written second so it wins on equal specificity, which is the one
   thing to preserve if these are ever reordered. */
.stock-row.is-low {
  border-color: rgba(192,57,43,0.4);
}

.stock-row.is-out {
  border-color: var(--danger);
  border-left-width: 3px;
  background: rgba(192,57,43,0.05);
}

.stock-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 8px;
}

.stock-brand {
  font-weight: 700;
  font-size: 0.9rem;
}

/* Three flags share this: Out of stock, Low, and Not counted. The neutral form
   is "not counted" — an uncounted product is not an alarm, and giving it the
   same red as an empty shelf would make the stocktake look like a crisis. */
.stock-flag {
  padding: 0.0625rem 0.4375rem;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text-dim);
  font-size: 0.66rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  white-space: nowrap;
}

.stock-flag.is-low {
  border-color: rgba(192,57,43,0.5);
  color: var(--danger);
}

.stock-flag.is-out {
  border-color: var(--danger);
  background: var(--danger);
  color: #fff;
}

/* The Edit button sits at the far end of the head row, past the flags.
   margin-left:auto rather than justify-content:space-between, because the flags
   between it and the brand are optional — a row with none would otherwise push
   the button against the name. */
.stock-toggle {
  margin-left: auto;
  flex: 0 0 auto;
}

/* A fourth state on the same pill, and the only one that is about the FORM
   rather than about the shelf: this row has edits nobody has saved. Amber, so
   it does not read as an alarm beside the red of an empty shelf. */
.stock-flag.is-draft {
  border-color: rgba(181,122,42,0.55);
  background: rgba(181,122,42,0.12);
  color: #8a5a14;
}

/* The one-line glance, shown only while the form is shut. A wrapping flex row
   rather than a grid: four short facts of very different widths, and at 320px
   they should stack in reading order rather than sit in four cramped columns. */
.stock-summary {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 14px;
  margin: 8px 0 0;
  font-size: 0.78rem;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

/* -- The listing form ------------------------------------------------------- */
/* Four sections of fields, a batch list and three read-outs, opened in place on
   a row of the stock list.

   EVERY GRID IN HERE IS auto-fit + minmax, AND CAPPED BY A max-width RATHER
   THAN BY A COLUMN COUNT. That is the whole responsive strategy and it is worth
   stating once: the sections then reflow on the space they are actually given,
   with no breakpoint to keep in step, and the cap is what stops four fields
   becoming seven tracks of 22rem on a 3200px screen — where --admin-col is
   2760px and a row of fields spread across all of it would be unreadable in a
   different way from a cramped one.

   Container queries would express this better and are deliberately NOT used
   here: see the note above .product-card for why they were removed from this
   stylesheet entirely. */

.listing-form {
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}

.listing-legend {
  margin: 0 0 14px;
  font-size: 0.74rem;
  color: var(--text-dim);
}

/* The asterisk. Its meaning is carried by aria-required on the control, so this
   is decoration — but decoration that has to be legible, hence the accent
   rather than the dim grey the caption beside it wears. */
.listing-req {
  margin-left: 0.15em;
  color: var(--danger);
  font-weight: 700;
}

.listing-section {
  margin-bottom: 18px;
}

.listing-section:last-of-type {
  margin-bottom: 0;
}

.listing-section-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 10px;
  margin: 0 0 10px;
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* The qualifier after a section name — "whole Kenyan shillings, no cents
   anywhere". Not uppercase and not tracked: it is a sentence, and setting a
   sentence as a heading label makes it unreadable. */
.listing-section-note {
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
}

.listing-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  gap: 12px 14px;
  max-width: 62rem;
}

/* Three fields rather than four, so they need more of each track before they
   are worth splitting. */
.listing-grid.is-naming {
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}

/* At desk width the naming row is not three equal columns: a generic name is
   "Amoxicillin 125mg/5ml suspension" and a brand face is a short menu, so equal
   thirds waste the space the long one needs. Applied only where there IS space
   — below this the auto-fit rule above takes over and they stack. */
@media (min-width: 900px) {
  .listing-grid.is-naming {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr) minmax(0, 0.85fr);
  }
}

.listing-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
  /* So the visually-hidden batch captions below resolve against their own
     field rather than against the page. */
  position: relative;
}

.listing-label {
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.listing-input {
  width: 100%;
  min-width: 0;
  padding: 0.4375rem 0.5625rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  background: var(--card);
  color: var(--text);
  font-family: inherit;
  font-size: 0.85rem;
}

.listing-input.is-money,
.listing-input.is-num,
.listing-input.is-mm,
.listing-input.is-yyyy {
  font-variant-numeric: tabular-nums;
}

/* A pack count and a price are read against each other down a column, so they
   are set to the right where the digits line up. The batch number and every
   text field stay left, because they are words. */
.listing-input.is-num,
.listing-input.is-money {
  text-align: right;
}

.listing-input:focus-visible {
  border-color: var(--accent);
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* READ-ONLY IS NOT DISABLED, and the difference is deliberate for the listing
   id: a disabled input cannot be focused, so the one value an administrator is
   most likely to want to COPY would be the one they could not select. Read-only
   keeps selection and the tab stop and refuses the edit. */
.listing-input[readonly] {
  background: var(--bg-soft);
  color: var(--text-dim);
  cursor: default;
}

.listing-input:disabled {
  background: var(--bg-soft);
  color: var(--text-dim);
  cursor: not-allowed;
}

.listing-field.is-bad .listing-input {
  border-color: var(--danger);
}

.listing-error {
  font-size: 0.7rem;
  font-weight: 600;
  color: var(--danger);
}

.listing-hint {
  font-size: 0.7rem;
  color: var(--text-dim);
}

/* The select wrapper exists to give the chevron something to be positioned
   against that is the CONTROL and not the control plus its caption. Same reason
   .field-select exists on the auth forms. */
.listing-select {
  position: relative;
  display: block;
}

.listing-select select {
  width: 100%;
  min-width: 0;
  padding: 0.4375rem 1.9rem 0.4375rem 0.5625rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  background: var(--card);
  color: var(--text);
  font-family: inherit;
  font-size: 0.85rem;
  /* The native arrow is removed and one drawn below, because two arrows is what
     you get otherwise on the platforms that draw their own — the bug the
     fulfilment dropdown shipped with. */
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
}

.listing-select::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 0.7rem;
  width: 0.4rem;
  height: 0.4rem;
  border-right: 2px solid var(--text-dim);
  border-bottom: 2px solid var(--text-dim);
  transform: translateY(-70%) rotate(45deg);
  pointer-events: none;
}

.listing-select select:focus-visible {
  border-color: var(--accent);
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.listing-field.is-bad .listing-select select {
  border-color: var(--danger);
}

/* "Not supplied in source", under its price box. A real <label> wrapping a real
   checkbox, so the words are part of the hit area — at 0.7rem the box alone is
   a 13px target. */
.listing-check {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 0.7rem;
  color: var(--text-dim);
  cursor: pointer;
}

.listing-check input {
  width: 0.95rem;
  height: 0.95rem;
  margin: 0;
  accent-color: var(--accent);
  flex: 0 0 auto;
}

/* The sentence under a section — the wholesale break, the cart ceiling, where
   assay figures belong. */
.listing-note {
  margin: 10px 0 0;
  max-width: 62rem;
  font-size: 0.72rem;
  line-height: 1.5;
  color: var(--text-dim);
}

/* -- Batches ---------------------------------------------------------------- */
/* A list of rows that LOOKS like a table at desk width and is not one, because
   a table cannot reflow: three columns of inputs and a delete button is about
   30rem of content that will not shrink, so the panel would scroll sideways on
   a phone — a control you have to scroll to reach the control. */

.batch-block {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-width: 62rem;
}

/* Hidden by default and shown only where the row is laid out in columns. Below
   that width each field carries its own visible caption instead, so these would
   be a second set of labels for the same boxes. aria-hidden in the markup: they
   are decoration either way, and the real captions are always in the DOM. */
.batch-headings {
  display: none;
}

.batch-empty {
  margin: 0;
  padding: 10px 12px;
  border: 1px dashed var(--border);
  border-radius: 8px;
  font-size: 0.75rem;
  color: var(--text-dim);
}

.batch-row {
  display: grid;
  gap: 8px 10px;
  padding: 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--card);
  /* Narrow first: the number and its delete button share a row, then the
     expiry, then the count. Named areas rather than source order, because the
     delete button is last in the DOM — where a keyboard should reach it — and
     top-right is where it belongs on screen. */
  grid-template-columns: minmax(0, 1fr) 2.25rem;
  grid-template-areas:
    "num   del"
    "exp   exp"
    "packs packs"
    "assay assay"
    "diss  diss";
}

/* POSITIONAL, so the row's DOM order and this list are one fact in two places.
   Assay and dissolution were added on 2026-09-14 as children 4 and 5, which
   pushed the delete button from 4 to 6 — miss that and the button lands in the
   assay cell while the assay input lands under the caret of the delete column,
   silently and only above 48rem. The count is asserted in the markup's comment
   beside .batch-headings for the same reason. */
.batch-row > :nth-child(1) { grid-area: num; }
.batch-row > :nth-child(2) { grid-area: exp; }
.batch-row > :nth-child(3) { grid-area: packs; }
.batch-row > :nth-child(4) { grid-area: assay; }
.batch-row > :nth-child(5) { grid-area: diss; }
.batch-row > :nth-child(6) { grid-area: del; }

/* A batch past its date is a real state and is recorded rather than refused —
   somebody has to be able to say what is on the shelf. Flagged, not blocked. */
.batch-row.is-expired {
  border-color: var(--danger);
  border-left-width: 3px;
  background: rgba(192,57,43,0.05);
}

.batch-flag {
  align-self: flex-start;
  margin-top: 2px;
  padding: 0.0625rem 0.4375rem;
  border-radius: 999px;
  border: 1px solid var(--danger);
  background: var(--danger);
  color: #fff;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

/* MM / YYYY, as printed on the carton. Two boxes and a slash rather than one
   free-text date, because the database stores exactly this pair — see migration
   017 for why an expiry is not a date. */
.batch-expiry {
  display: flex;
  align-items: center;
  gap: 6px;
}

.batch-expiry-slash {
  color: var(--text-dim);
  font-size: 0.85rem;
}

/* Fixed-ish widths so the pair reads as one date rather than as two fields that
   happen to be adjacent. flex rather than width alone, so they still shrink
   before they overflow a 320px column. */
.listing-input.is-mm { flex: 0 1 3.25rem; text-align: center; }
.listing-input.is-yyyy { flex: 0 1 4.5rem; text-align: center; }

.batch-remove {
  align-self: end;
  justify-self: end;
  width: 2.25rem;
  height: 2.25rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  background: var(--card);
  color: var(--text-dim);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  transition: border-color 0.15s ease, color 0.15s ease;
}

@media (hover: hover) {
  .batch-remove:hover {
    border-color: var(--danger);
    color: var(--danger);
  }
}

.batch-remove:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.batch-add {
  align-self: flex-start;
}

/* Columns once there is room for them. Below this the row stacks, which is the
   base rule above. */
@media (min-width: 48rem) {
  .batch-headings {
    display: grid;
    grid-template-columns:
      minmax(0, 1.1fr) minmax(0, 0.9fr) minmax(0, 0.8fr)
      minmax(0, 1fr) minmax(0, 1fr) 2.25rem;
    gap: 8px 10px;
    padding: 0 10px;
    font-size: 0.66rem;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-dim);
  }

  /* SIX COLUMNS, and the three original ones give up width rather than the row
     growing: this panel already sits inside the admin column, and a sixth
     column added at the old widths would push the delete button off the end on
     a 1280px screen. The two new ones are the widest of the six because a
     release figure is a sentence — "95.0-105.0% of label claim (HPLC)" — while
     a pack count is four digits. */
  .batch-row {
    grid-template-columns:
      minmax(0, 1.1fr) minmax(0, 0.9fr) minmax(0, 0.8fr)
      minmax(0, 1fr) minmax(0, 1fr) 2.25rem;
    grid-template-areas: "num exp packs assay diss del";
    align-items: start;
  }

  /* The column headings above now say what each box is, so the per-field
     captions would be a second copy on screen. Hidden from SIGHT only — they
     stay in the accessibility tree, because the headings row is aria-hidden and
     a box with no name is a box a screen reader cannot describe. */
  .batch-row .listing-label.is-inline {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }
}

/* -- The three read-outs ---------------------------------------------------- */
/* Total on hand, the low-stock level, and what a cart line would be capped at.
   None is an input: they are what the batches above add up to, and a box
   holding a derived number is a box somebody will type into. */

.listing-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: 10px;
  margin-top: 12px;
  max-width: 48rem;
}

.listing-stat {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-soft);
}

.listing-stat-label {
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.listing-stat-value {
  font-size: 1.25rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1.2;
}

/* "packs" beside the figure, at the size of the note rather than of the number
   — it is a unit, not part of the value. */
.listing-stat-value small {
  font-size: 0.72rem;
  font-weight: 500;
  color: var(--text-dim);
}

.listing-stat-note {
  font-size: 0.68rem;
  line-height: 1.4;
  color: var(--text-dim);
}

/* -- Saving ----------------------------------------------------------------- */

.listing-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-top: 16px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}

/* role="status" in the markup, so this is announced when it changes — "Fix the
   fields marked above" reaching only the eye would leave a keyboard user with a
   disabled Save button and no stated reason. */
.listing-state {
  font-size: 0.72rem;
  color: var(--text-dim);
}

/* TOUCH TARGETS. Grown on a coarse pointer only: these are dense forms by
   design at desk width, and 44px rows would make a four-field section as tall
   as the screen with a mouse in hand. */
@media (pointer: coarse) {
  .listing-input,
  .listing-select select {
    min-height: 2.75rem;
    font-size: 16px;   /* iOS zooms a focused field under 16px */
  }

  .listing-check input {
    width: 1.15rem;
    height: 1.15rem;
  }

  .batch-remove {
    width: 2.75rem;
    height: 2.75rem;
  }
}

/* -- Pack photographs, on the stock row ------------------------------------- */
/* Separated from the number boxes above by a rule rather than by space alone:
   the fields are a draft that needs Save pressed, these write through the
   moment a file is chosen, and the two behave differently enough that they
   should not read as one form. */
.stock-photos {
  margin-top: 12px;
  padding-top: 10px;
  border-top: 1px dashed var(--border);
}

.stock-photos-head {
  display: block;
  margin-bottom: 8px;
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* Two slots side by side where there is room and stacked where there is not.
   minmax(0, …) rather than a bare 1fr: a grid track's default minimum is
   min-content, and a long filename inside the file input would otherwise push
   the column wider than the panel. */
.stock-photo-slots {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
  gap: 10px;
}

.stock-photo {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  min-width: 0;
}

/* FIXED HEIGHT, WIDTH FROM THE PICTURE — the same "fill the frame, trim
   nothing" rule the storefront's tiles follow, turned through ninety degrees
   because of where this one sits. Two of these ride inside every stock row, and
   the rows stack down a page an administrator scrolls: an even HEIGHT is what
   keeps that stack from jolting as the photographs load, so the height is the
   dimension that stays put and the width is the one free to follow
   --pack-aspect. Letting it is what takes the empty strip out from beside a
   portrait carton.

   Small on purpose. This is a check that the right picture is in the right
   slot, not a viewer — the storefront's images view is the viewer.

   72px SQUARE IS THE FALLBACK and covers three cases from one place: an empty
   slot, a photograph that has not decoded yet, and a browser that ignores the
   custom property. The clamp on the ratio (PACK_ASPECT_MIN/MAX) is what stops a
   freak panorama from taking 400px of a table row — it cannot get wider than
   144px or narrower than 36px. */
.stock-photo-frame {
  display: flex;
  align-items: center;
  justify-content: center;
  width: calc(72px * var(--pack-aspect, 1));
  height: 72px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--card);
  overflow: hidden;
}

/* `contain`, never `cover` — the same single keyword the storefront's
   PRODUCT_IMAGE_FIT argues for at length. A centre crop of a carton keeps the
   brand and loses the dose, and it is no more acceptable in a 72px check than
   it is in the zoom. It stays for the same reason it stays on .pack-image-img:
   the frame matching the picture is what removes the empty strip, and THIS is
   what guarantees nothing is cut off when the two ever disagree.

   width/height rather than the max-* pair they replaced. `object-fit` describes
   how a replaced element fills the box it was GIVEN, so with only maxima the
   image sized itself to its own 1000px, clamped, and the keyword had no box to
   act on — it was inert, and would have gone on being inert if `cover` were
   ever pasted over it. Given a definite box it does the job it is named for. */
.stock-photo-img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.stock-photo-empty {
  padding: 0 6px;
  color: var(--text-dim);
  font-size: 0.62rem;
  line-height: 1.3;
  text-align: center;
}

.stock-photo-name {
  font-size: 0.72rem;
  font-weight: 700;
  color: var(--text);
}

/* The native control, styled rather than hidden — see .field input[type="file"]
   for the argument. Repeated here rather than shared because that selector is
   scoped to .field, and widening it to every file input in the application
   would make a form's rules answerable for a panel it knows nothing about. */
.stock-photo-input {
  max-width: 100%;
  padding: 0;
  cursor: pointer;
  font-family: inherit;
  font-size: 0.72rem;
  color: var(--text-dim);
}

.stock-photo-input::file-selector-button {
  margin-right: 8px;
  padding: 0.25rem 0.5rem;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-family: inherit;
  font-size: 0.72rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 0.15s ease;
}

@media (hover: hover) {
.stock-photo-input:hover::file-selector-button { border-color: var(--accent); }
}

.stock-photo-input:disabled { cursor: default; opacity: 0.55; }

.stock-photo-remove {
  padding: 0.1875rem 0.5rem;
  font-size: 0.7rem;
}

/* One line under both slots. `role="status"` on the element, so a resize that
   happened is announced rather than only shown — an administrator who has just
   uploaded a 4000px photograph has no other way to learn it was reduced. */
.stock-photo-note {
  margin: 8px 0 0;
  font-size: 0.72rem;
  line-height: 1.4;
  color: var(--text-dim);
}

/* -- The nav link ----------------------------------------------------------- */
/* An <a> carrying .btn, so it matches the buttons either side of it without
   .btn's own rules needing to know an anchor can wear them. Only the
   text-decoration needs undoing. */
.nav-admin {
  text-decoration: none;
  white-space: nowrap;
}

/* -- Bulk upload ------------------------------------------------------------ */
/* The token-guarded console's paste-and-map uploads, folded into the Stock
   dashboard on 2026-08-23. admin.css styled these as full-width panels on a page
   of their own; here three of them share a tab with the row editor, so they are
   collapsed disclosures instead and borrow the storefront's own field skin
   rather than bringing a second one with them. */

.bulk-intro {
  margin: -6px 0 14px;
  max-width: 60ch;
}

.bulk-upload {
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-soft);
  margin-bottom: 10px;
  overflow: hidden;
}

/* The whole header row is the control, so the blurb is part of the hit target
   rather than text beside a small chevron. Grid and not flex: the title and the
   blurb share a column so they stack against a chevron that keeps its own. */
.bulk-toggle {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 2px 12px;
  width: 100%;
  padding: 12px 14px;
  border: 0;
  background: none;
  text-align: left;
  cursor: pointer;
}

@media (hover: hover) {
  .bulk-toggle:hover .bulk-title { color: var(--accent); }
}

.bulk-title {
  grid-column: 1;
  font-size: 0.9rem;
  font-weight: 700;
  color: var(--text);
}

.bulk-blurb {
  grid-column: 1;
  font-size: 0.78rem;
  line-height: 1.4;
  color: var(--text-dim);
}

/* Spans both rows so it centres against the pair, and rotates to point down
   when the section is open — the same affordance as .disclosure-chevron. */
.bulk-chevron {
  grid-column: 2;
  grid-row: 1 / span 2;
  color: var(--text-dim);
  transition: transform 0.15s ease;
}

.bulk-toggle.is-open .bulk-chevron { transform: rotate(180deg); }

.bulk-body {
  padding: 0 14px 14px;
  border-top: 1px solid var(--border);
}

.bulk-body .field { margin-top: 12px; }

/* Monospace, because what is pasted in here is columns. A proportional face
   makes a tab-separated block look ragged and hides a misaligned row, which is
   the one thing the operator is scanning for. */
.bulk-body textarea {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.78rem;
  white-space: pre;
  overflow-wrap: normal;
  overflow-x: auto;
}

.bulk-section {
  margin: 16px 0 8px;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* One cell per field. 13rem is the width at which a caption, its select and a
   two-line note all read without wrapping mid-word; below that the grid drops
   to one column on its own. */
.bulk-map {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  gap: 10px;
}

.bulk-map-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
}

.bulk-map-field .field-select { width: 100%; }

.bulk-map-field select {
  width: 100%;
  padding: 0.375rem 1.75rem 0.375rem 0.5rem;
  font-size: 0.8rem;
  border-radius: 0.5rem;
}

/* The asterisk on a required column. --danger rather than --accent: it marks
   the fields that will stop the upload, and it sits next to a select that may
   legitimately read "not supplied" for every optional one. */
.bulk-req {
  color: var(--danger);
  font-weight: 700;
}

.bulk-map-note {
  font-size: 0.7rem;
  line-height: 1.4;
  color: var(--text-dim);
}

/* A row the parsers refused. Tinted rather than outlined, because these appear
   in runs — a whole column mis-mapped fails every row — and forty outlined rows
   is a striped table nobody can read.

   THIS RULE NO LONGER DECIDES ANYTHING ON ITS OWN. Every one of these rows is
   inside an .admin-table, where the zebra stripe outranks a bare class; the rule
   that wins is .admin-table tbody tr.bulk-row-bad, up in the Tables section
   beside the stripe it has to beat. Kept here because this is where a reader
   looks the class up, and pointed at the same token so the two cannot drift. */
.bulk-row-bad { background: var(--admin-row-bad); }

.bulk-cell-error {
  color: var(--danger);
  font-size: 0.76rem;
}

/* An optional field the operator did not supply. Dimmed rather than blank, so
   the cell reads as deliberately empty instead of as a rendering fault. */
.bulk-absent { opacity: 0.4; }

.bulk-progress {
  height: 6px;
  margin: 14px 0 6px;
  border-radius: 999px;
  background: var(--border);
  overflow: hidden;
}

.bulk-progress-fill {
  height: 100%;
  background: var(--accent-2);
  transition: width 0.2s ease;
}

/* The unknown product ids, printed in full. Monospace and wrapping, because it
   is a list of codes to be read against a spreadsheet — and it can be long, so
   it scrolls rather than pushing the panel down the page. */
.bulk-idlist {
  max-height: 9rem;
  margin-top: 8px;
  padding: 8px 10px;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--card);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.74rem;
  line-height: 1.6;
  overflow-wrap: anywhere;
}

.bulk-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-top: 16px;
}

/* -- Admin note ------------------------------------------------------------- */
/* The console's three-tone note. All three earned their place: an upload that
   half-worked is neither a success nor a failure, and "warn" is the tone that
   says so — 400 rows priced and 12 ids that matched nothing. */

.admin-note {
  margin: 14px 0 0;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-left-width: 3px;
  border-radius: 8px;
  background: var(--card);
  font-size: 0.82rem;
  line-height: 1.5;
}

.admin-note h4 {
  margin: 0 0 4px;
  font-size: 0.82rem;
  font-weight: 700;
}

.admin-note p { margin: 0 0 6px; }
.admin-note p:last-child { margin-bottom: 0; }

.admin-note.is-good {
  border-color: var(--accent-2);
  background: rgba(10,122,104,0.06);
}

.admin-note.is-warn {
  border-color: #b8860b;
  background: rgba(184,134,11,0.07);
}

.admin-note.is-bad {
  border-color: var(--danger);
  background: rgba(192,57,43,0.06);
  color: var(--danger);
}

/* The heading keeps the body's ink inside a bad note — the whole block is
   already --danger, and a red heading on a red body is one emphasis too many. */
.admin-note.is-bad h4 { color: var(--danger); }

/* ==== END: Admin dashboards ================================================ */


/* ==== END: Auth card ======================================================= */


/* ==== BEGIN: Form fields =================================================== */
/* Drives the Field component plus the checkbox rows in both auth forms. */

/* `display: block` IS THE FIX, and the margin under it is the bug it fixes.

   16px has been written here since the first build and has never once been
   applied. .field IS the <label> — see <Field /> — and a <label> is inline,
   and vertical margins do not apply to a non-replaced inline box. So every
   stacked field in this application sat with ZERO between one input's bottom
   edge and the next caption, and the only thing that ever looked like spacing
   was the caption's own leading.

   It went unnoticed for so long because the symptom is invisible until
   something lands directly under an input with no caption of its own to lend
   its leading. On the sign-in form that is the Sign in button, which sat flush
   against the password box — reported on 2026-08-31 as "add spacing between
   password field and the sign in button", which is this, at the one place in
   the app where the gap had nothing to hide behind.

   TWO EARLIER RULES ALREADY KNEW. .field-pair's children are flex items, which
   blockifies them, and <FileField /> renders a <div>; both had the margin come
   to life by accident and both ZEROED it to match the zero everything else had.
   That is the tell — the rhythm was being copied FROM the bug. Now that 16px
   applies everywhere, .field-pair is re-pointed at it below, and the file
   field's `.file-field` override is gone entirely along with the class that
   carried it, which had no other declaration to its name.

   Applying it here rather than scoping it to .auth-form deliberately: the value
   is the form rhythm for the whole application, not a sign-in special case, and
   the composer and the bulk paste label were cramped in exactly the same way
   and for exactly the same reason. */
.field {
  display: block;
  margin-bottom: 16px;
}

/* .field IS the <label> — the caption is a <span> inside it, so this cannot be
   `.field label`. */
.field-label {
  display: block;
  font-size: 0.82rem;
  color: var(--text-dim);
  margin-bottom: 6px;
  font-weight: 600;
}

/* The composer's message box is the same control from the customer's side as
   an auth input, so it takes the same skin from one rule rather than a copy
   that can drift. Only what is genuinely textarea-only is split out below.
   The county <select> joins them for the same reason — one skin, three tags. */
.field input,
.field textarea,
.field select {
  width: 100%;
  padding: 0.6875rem 0.8125rem;
  border-radius: 9px;
  border: 1px solid var(--border);
  background: var(--bg-soft);
  color: var(--text);
  font-family: inherit;
  font-size: 0.94rem;
  outline: none;
  transition: border-color 0.15s ease;
}

/* Vertical only: a horizontal drag would push the textarea past the modal. */
.field textarea {
  display: block;
  min-height: 118px;
  line-height: 1.5;
  resize: vertical;
}

.field input:focus,
.field textarea:focus,
.field select:focus { border-color: var(--accent); }

/* Keyed to the attribute the Field component actually sets. It used to be
   `.field input.invalid`, a class nothing ever added, so a field that failed
   validation showed its message but kept a neutral border. */
.field input[aria-invalid="true"],
.field textarea[aria-invalid="true"],
.field select[aria-invalid="true"] { border-color: var(--danger); }

/* -- Select field ---------------------------------------------------------- */
/* The county picker. The native arrow is suppressed and redrawn as a
   pseudo-element, the same trick .select-wrap plays in the products toolbar —
   but that class cannot be reused here: it is an inline-flex row sized for a
   toolbar caption, and this one is a block control filling a form. */

.field select {
  appearance: none;
  -webkit-appearance: none;
  /* Right padding clears the chevron; without it a long county name runs
     underneath it. */
  padding-right: 34px;
  cursor: pointer;
}

/* Wraps the <select> ALONE. The chevron cannot be anchored to .field, which is
   the whole label and contains the caption too — centring against that box puts
   the arrow above the control. */
.field-select {
  position: relative;
  display: block;
}

.field-select::after {
  content: "▾";
  position: absolute;
  top: 50%;
  right: 13px;
  transform: translateY(-50%);
  font-size: 0.7rem;
  color: var(--text-dim);
  pointer-events: none;
}

/* -- Field pair ------------------------------------------------------------ */
/* Two fields on one row, for two halves of a single answer — county and city.

   The 170px BASIS is what makes this need no media query of its own: two of them
   plus the gap ask for 352px, which the 440px modal grants side by side and a
   375px phone (314px of form) cannot, so the pair stacks itself exactly where two
   controls stop fitting. A breakpoint would have to guess that width.

   Why 170 and not 150, which also fitted the desktop: at 150 the pair squeezed
   onto one row at 375px too, 151px each, and 151px leaves 104px inside the
   select's padding — measured against "Elgeyo-Marakwet" at ~118px, so the
   longest county names clipped. The basis is set by the WIDEST OPTION, not by
   what merely fits.

   `min-width: 0` because a flex item's default min-width is auto — without it
   the longest county name sets a floor under the <select> and the pair pushes the
   modal past its own max-width instead of wrapping. */
/* row-gap 16px, column-gap 12px, and the row carries the form's own bottom
   margin. REWRITTEN 2026-08-31 when .field stopped being inline.

   It used to read `gap: 0 12px` with no bottom margin, and the reasoning was
   sound from a false premise: two consecutive fields elsewhere had ZERO between
   them, so this pair matched the zero. That zero was the dormant-margin bug —
   see .field above — so the pair was calibrated against it and is now
   re-calibrated against the rhythm that actually exists.

   The gap does the work in one axis at a time, which is what the pair needs:
   side by side on a wide modal the row-gap never applies and only the 12px
   between the two columns is real; wrapped onto two rows at 375px the 16px
   row-gap puts county and city on the same rhythm as every other stacked
   field. The bottom margin is the row's own place in that rhythm — without it
   the pair would be the one thing on the form with nothing under it. */
.field-pair {
  display: flex;
  flex-wrap: wrap;
  gap: 16px 12px;
  margin-bottom: 16px;
}

/* The margin is ZEROED ON THE CHILDREN and moved to the row above, which is the
   same arrangement as before for a different reason. Before, .field's margin
   was live here by accident — a flex item cannot stay inline, so blockification
   brought it up on these two labels alone — and zeroing it was how the pair was
   made to match a form with no spacing in it. Now the margin is live
   EVERYWHERE, and it is zeroed here because a gap and a margin between the same
   two boxes would stack: 32px between county and city on a phone. The row-gap
   is the better of the two, because it collapses to nothing when the pair
   shares one line and a margin would not. */
.field-pair .field {
  flex: 1 1 170px;
  min-width: 0;
  margin-bottom: 0;
}

.field-error {
  display: block;
  color: var(--danger);
  font-size: 0.78rem;
  margin-top: 5px;
}

/* -- Auth failure ---------------------------------------------------------- */
/* A refusal that belongs to the whole form rather than to one control: "too many
   codes requested for that number", "that mobile number or username is already
   registered". These come back from /api/auth, so unlike .field-error there is
   no single input to sit beneath -- it goes above the form, where the eye lands
   before re-reading fields that may all be individually fine.

   Solid rather than dashed, and the distinction outlived what it was drawn
   against: the dashed --danger panel it separated itself from (.otp-issued) was
   a stand-in for a message that did not happen, where this is an answer that
   did. Solid still says "answer" on its own, so the border stays as it is. */
.auth-failure {
  margin: 0 0 16px;
  padding: 0.625rem 0.75rem;
  border: 1px solid var(--danger);
  border-left-width: 3px;
  border-radius: 8px;
  background: rgba(192,57,43,0.06);
  color: var(--danger);
  font-size: 0.86rem;
  line-height: 1.45;
}

/* -- Field hint ------------------------------------------------------------ */
/* A line under a control that is NOT an error: the problem statement's word
   counter, and the attachment's format-and-size note. Same size as .field-error
   so the two occupy one rhythm, but dim rather than red — it carries no failure.

   `.is-over` is the counter past its limit. It borrows --danger for the colour and
   nothing else: no icon, no weight change, because at that moment nothing is
   broken yet. The refusal comes on submit, from .field-error, and if this looked
   the same as that there would be no way to tell "getting long" from "too long". */
.field-hint {
  display: block;
  color: var(--text-dim);
  font-size: 0.78rem;
  margin-top: 5px;
}

.field-hint.is-over { color: var(--danger); }

/* Right-aligned so it reads as a meter against the box's edge rather than as a
   second caption. Only where a counter is the hint — the attachment's note is a
   sentence and stays left, and it follows an <input>, not a <textarea>.

   ONE general-sibling selector, not the adjacent-sibling pair this started as
   (`textarea + .field-hint` plus `textarea ~ .field-error + .field-hint`): the
   error span appears between the two only when validation has failed, so the
   adjacent form needed a second rule for the failed state and would have needed a
   third for anything else that ever sits in between. `~` does not care what is
   between them. */
.field textarea ~ .field-hint {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* -- File field ------------------------------------------------------------ */
/* The one field that is a <div> rather than a <label> — see <FileField />.

   ITS `margin-bottom: 0` IS GONE, 2026-08-31. This was the second rule written
   against the dormant-margin bug: being a <div> it was already a block, so
   .field's 16px was live here and nowhere else, and it was zeroed to stop this
   one row standing 16px looser than a form that had no spacing anywhere. Now
   .field is `display: block` and 16px is the rhythm, so the override would make
   the attachment the only field on the sign-up form with nothing under it —
   the exact fault it was added to prevent, in reverse. Nothing replaces it: the
   shared rule is what this row wanted all along. */

/* The input keeps the shared skin from `.field input` above; only what is
   file-specific is set here.

   5px of vertical padding, not the 11px a text input carries, and the figure is
   measurement rather than taste: this control's height is its BUTTON's height plus
   this padding, and the button measures 32.7px at the size set below. 1.33 border
   + 10 padding + 32.7 = 44px against a text input's 43.33px — the two rows read as
   one height. At 8px it stood 50px, 7px taller than every neighbour for no reason
   a reader could see.

   4.5px would land on 43.33px exactly, and is deliberately not used: sub-pixel
   padding rounds differently at other zoom levels, which trades a permanent 0.67px
   for an occasional 1px. Re-measure if the button's padding or font-size moves. */
.field input[type="file"] {
  padding: 0.3125rem 0.625rem;
  height: auto;
  cursor: pointer;
  font-size: 0.85rem;
}

/* The browser's own button, restyled to match .btn rather than replaced by a
   label over a hidden input — which is the usual trick and loses the focus ring,
   the keyboard activation and the filename readout all at once. */
.field input[type="file"]::file-selector-button {
  margin-right: 12px;
  padding: 0.4375rem 0.8125rem;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-family: inherit;
  font-size: 0.82rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 0.15s ease;
}

@media (hover: hover) {
.field input[type="file"]:hover::file-selector-button { border-color: var(--accent); }
}

/* Carries the "remember me" checkbox plus, on the login form, the
   "Forgot password?" link — hence space-between, with the label supplying its
   own flex so the box and its text stay together. */
.checkbox-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin: -2px 0 18px;
  color: var(--text-dim);
  font-size: 0.85rem;
}

.checkbox-row label {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.checkbox-row input { accent-color: var(--accent); }

/* -- Choice group ---------------------------------------------------------- */
/* Drives the ChoiceField component — the sign-up form's "Which of these
   describes you?". Same vertical rhythm as .field above so the two sit in one
   stack without a seam. */

.choice-field {
  /* A <fieldset> ships a border, its own padding and `min-width: min-content`.
     All three have to go: the first two box the group off from the fields above
     and below it, and the third stops it shrinking inside the auth card, which
     would push the whole modal wider than the viewport on a phone. */
  min-width: 0;
  padding: 0;
  border: 0;

  /* 12px, matching the measured gap between two .field labels — NOT the 16px
     those labels declare. A <label> is inline, and vertical margins do not apply
     to a non-replaced inline box, so .field's own margin-bottom has never done
     anything: the 12px comes from the line boxes. A fieldset is a block, its
     margin does apply, and 16px here left this one group sitting 4px lower than
     every other row in the form. */
  margin: 0 0 12px;
}

/* The caption is a <legend> carrying .field-label, and a legend has inline
   padding of its own — left alone it sits 2px right of every other caption in
   the form. */
.choice-field legend { padding: 0; }

/* A GRID, not the wrapping flex row that .contact-tabs uses, and the difference
   is the fifth option. Five flex items with flex-grow leave whichever one lands
   alone on the last row stretched across the full width — with five answers that
   is "Other", so the least likely answer became the biggest target on screen and
   the pills all ended up different widths, which reads as a hierarchy the
   answers do not have.

   auto-fit + minmax gives equal columns instead: two of them wherever the modal
   is wide enough for two 140px pills, one on a very narrow phone, and the widest
   caption ("A pharmacist", ~139px) fits the 140px floor at every size. The last
   row simply ends early. */
.choice-group {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 8px;
}

/* The pill IS the <label>, so the whole of it is the hit target, not just the
   words. The radio inside stays visible rather than being hidden behind a styled
   pill: it is what says "one of these", and it is what the focus ring lands on.

   inline-flex for the radio-beside-caption alignment; as a grid item it is
   blockified to plain flex and fills its column, which is what makes the pills
   equal-width without a width declaration anywhere. */
.choice {
  display: inline-flex;
  align-items: center;
  gap: 0.4375rem;
  padding: 0.5625rem 0.8125rem;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--bg-soft);
  color: var(--text-dim);
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.15s ease;
}

@media (hover: hover) {
.choice:hover { border-color: var(--accent); }
}

.choice input {
  margin: 0;
  accent-color: var(--accent);
  cursor: pointer;
}

/* Set from ChoiceField rather than by :has(input:checked), matching
   .contact-tab.active — React already holds which one is picked. */
.choice.checked {
  background: var(--card);
  border-color: var(--accent);
  color: var(--text);
}

/* Keyed to the attribute ChoiceField actually sets, the way the text inputs
   are. The group failed, so every pill in it carries the failed border — there
   is no one control to mark, which is the whole difference between validating a
   choice and validating a field. */
.choice-field[aria-invalid="true"] .choice { border-color: var(--danger); }

/* ==== END: Form fields ===================================================== */


/* ==== BEGIN: Scroll reveal ================================================= */
/* The two closing bands — the selling points and the company profile — fade and
   lift into place the first time they come on screen. useRevealOnScroll() puts
   `reveal-band` on the section and adds `is-revealed` when it arrives. Nothing
   in this block starts on its own, and nothing in it is a keyframe animation:
   it is one transition, run once, by a class changing.

   ONLY opacity AND transform, deliberately. Neither takes an element out of flow
   or changes its size, so the page is exactly as tall before the reveal as
   after. That is what keeps an `#about` deep link landing on the profile heading
   — a reveal built out of `display: none`, or out of height, moves every offset
   below it and the anchor arrives somewhere that stopped existing the moment the
   band unfolded. It is also why the revealed state ends at `transform: none`
   rather than `translateY(0)`: a transform that is merely zero still makes the
   element a containing block for fixed descendants, and `none` is the only value
   that stops doing so.

   FOUR SELECTORS RATHER THAN A CLASS PER ELEMENT. The items are addressed from
   the band, so neither <Features /> nor the shared <Disclosure /> had to grow a
   prop to carry a class it does not otherwise want — which for Disclosure is not
   a nicety: it takes a fixed set of props and silently drops anything else, and
   it has four other call sites (the category rail, the fifty-category index, the
   showcase tiles and the account panel) that must not inherit a word of this.
   `.profile-list > .disclosure` is what keeps them out — a bare `.disclosure`
   would reach all four, and a descendant selector would reach any that later
   moved inside a revealing band.

   `> h2` and `> p` can be written unqualified because `reveal-band` only ever
   lands on `.features` and `.profile`, and a third band opting in should pick up
   the same treatment for its own heading — that is the intent, not an accident
   of scope.

   The stagger is transition-delay by nth-child: three feature cards and four
   profile rows, written out one by one. The un-numbered rule above each list is
   the floor, not decoration — add a fifth row and it reveals with the fourth
   instead of snapping in unstaggered, which is wrong quietly rather than wrong
   loudly.

   Nothing here re-declares `transition` on `.disclosure-toggle` or on
   `.feature-chevron`. Both already own one — the toggle's border/background
   hover fade and the chevron's rotate — and the shorthand REPLACES rather than
   adds, so a reveal written one element lower would have taken those away. */

.reveal-band > h2,
.reveal-band > p,
.reveal-band .feature-card,
.reveal-band .profile-list > .disclosure {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity 0.55s ease, transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}

.reveal-band.is-revealed > h2,
.reveal-band.is-revealed > p,
.reveal-band.is-revealed .feature-card,
.reveal-band.is-revealed .profile-list > .disclosure {
  opacity: 1;
  transform: none;
}

/* Heading first — it carries no delay of its own — then the subhead under it. */
.reveal-band > p { transition-delay: 0.07s; }

.reveal-band .feature-card { transition-delay: 0.3s; }
.reveal-band .feature-card:nth-child(1) { transition-delay: 0.14s; }
.reveal-band .feature-card:nth-child(2) { transition-delay: 0.22s; }
.reveal-band .feature-card:nth-child(3) { transition-delay: 0.3s; }

/* Tighter steps than the cards take: four rows rather than three, and they are
   stacked, so the eye travels the whole list instead of across it. */
.reveal-band .profile-list > .disclosure { transition-delay: 0.32s; }
.reveal-band .profile-list > .disclosure:nth-child(1) { transition-delay: 0.08s; }
.reveal-band .profile-list > .disclosure:nth-child(2) { transition-delay: 0.16s; }
.reveal-band .profile-list > .disclosure:nth-child(3) { transition-delay: 0.24s; }
.reveal-band .profile-list > .disclosure:nth-child(4) { transition-delay: 0.32s; }

/* KEYBOARD FOCUS SHOWS THE BAND AT ONCE. opacity leaves an element in the tab
   order and paints its focus ring at zero alpha with everything else, so Tab can
   land on a profile toggle inside a band that has not revealed yet — sequential
   focus only CENTRES an element that is wholly off screen, and bottom-aligns one
   that is already partly in view, which can leave the band under the 20% gate
   with a focused control inside it. <Disclosure /> refuses that arrangement one
   level down and says why: a list holding reachable buttons nobody can see is a
   keyboard trap.

   `transition: none` rather than letting it fade, and it is the whole point of
   the rule. A focus ring that takes 0.55s to arrive — 0.87s on the fourth row,
   once its stagger is counted — is a focus ring a keyboard reader has already
   moved past. This shows the band on the frame focus lands.

   AFTER the stagger rules, deliberately: `:focus-within` ties those on
   specificity, so source order is what decides transition-delay, and a delay
   surviving here would put the snap back to being a fade.

   No `pointer-events: none` on the hidden state, though it would stop a mouse
   reaching a control it cannot see. It would have to be undone again in all four
   rules below, and one missed `pointer-events: auto` leaves the whole About band
   permanently unclickable — a far worse failure than the one it prevents, which
   needs someone to deliberately click blank cream. `:focus-within` covers the
   click too: the band is visible before the toggle has finished being pressed. */
.reveal-band:focus-within > h2,
.reveal-band:focus-within > p,
.reveal-band:focus-within .feature-card,
.reveal-band:focus-within .profile-list > .disclosure {
  opacity: 1;
  transform: none;
  transition: none;
}

/* MOTION IS A PREFERENCE, and an entrance is decoration by definition.
   useRevealOnScroll() already declines to hide anything when the preference is
   set at load, so what this block covers is the preference being turned ON while
   the page is open: the hook has made its one decision by then and the class is
   already on the section. Same selectors as the hidden rule, and after it, which
   is what lets it win — a shorter selector here would lose and the band would
   stay blank, which is the exact failure this rule exists to prevent. */
@media (prefers-reduced-motion: reduce) {
  .reveal-band > h2,
  .reveal-band > p,
  .reveal-band .feature-card,
  .reveal-band .profile-list > .disclosure {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* A printed page has no scrolling to reveal anything with, so a print run that
   inherited the hidden state would put two blank bands on the paper. */
@media print {
  .reveal-band > h2,
  .reveal-band > p,
  .reveal-band .feature-card,
  .reveal-band .profile-list > .disclosure {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* ==== END: Scroll reveal =================================================== */


/* ==== BEGIN: Features ====================================================== */
/* Marketplace selling points (shipping, vetting, returns). */

.features {
  padding: 0 0 80px;
}

.features h2 {
  text-align: center;
  font-size: 1.6rem;
  margin-bottom: 10px;
}

.features > p {
  text-align: center;
  color: var(--text-dim);
  line-height: 1.55;
  margin-bottom: 40px;
}

.feature-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 20px;
}

.feature-card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 24px;
}

/* The tinted square behind a feature card's icon. Both colours come from that
   icon's own pair in ICON_TONES, written onto this element as --ic-tint and
   --ic-ink by iconToneStyle(): the square takes the tint, and `color` is what
   the icon strokes itself in, since the feature cards render <Icon plain />.
   The fallbacks keep it looking deliberate if an icon has no tone.

   font-size is the icon's own size, and runs above the 1.15rem the emoji used
   because a stroked icon reads smaller in the same em box. */
.feature-icon {
  width: 2.625rem;
  height: 2.625rem;
  border-radius: 10px;
  background: var(--ic-tint, var(--bg-soft));
  color: var(--ic-ink, var(--text));
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.4rem;
  margin-bottom: 14px;
}

.feature-card h3 {
  margin: 0 0 8px;
  font-size: 1.02rem;
}

.feature-card p {
  color: var(--text-dim);
  font-size: 0.9rem;
  line-height: 1.6;
  margin: 0;
}

/* ==== END: Features ======================================================== */


/* ==== BEGIN: Features — collapsible (small screens) ======================== */
/* The .collapsible class is added by the Features component when
   useIsSmallScreen() is true; card padding moves onto the toggle and body. */

.feature-grid.collapsible .feature-card {
  padding: 0;
  overflow: hidden;
}

.feature-toggle {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 16px 20px;
  background: none;
  border: none;
  color: var(--text);
  text-align: left;
}

.feature-toggle .feature-icon {
  width: 2.125rem;
  height: 2.125rem;
  flex-shrink: 0;
  margin-bottom: 0;
  font-size: 1.2rem;
}

.feature-toggle h3 {
  flex: 1;
  margin: 0;
  font-size: 1rem;
}

.feature-chevron {
  flex-shrink: 0;
  color: var(--text-dim);
  font-size: 0.9rem;
  transition: transform 0.2s ease;
}

.feature-card.open .feature-chevron {
  transform: rotate(180deg);
}

.feature-grid.collapsible .feature-card p {
  padding: 0 20px 18px;
}

/* ==== END: Features — collapsible (small screens) ========================== */


/* ==== BEGIN: Company profile =============================================== */
/* Four <Disclosure /> rows — mission, vision, the About text, the six values —
   the first of them open on arrival. The rows come styled from the Disclosure
   block above; what is here is the band around them and the prose inside them.

   THE CAP MOVED OFF THE ROWS AND ONTO THE PROSE, 2026-09-20, on request. The
   band used to be a capped, centred 820px column — toggles and copy together —
   and this comment used to justify that with "a 1160px line of body text does
   not read". The reading half of that is still true and is still enforced; the
   part that was never argued for is that a BUTTON needs a measure. At 820px the
   four rows sat visibly inset from the feature cards in the band directly above
   them, which is what the request was about, so `.profile-list` lost its
   max-width and its now-pointless `margin: 0 auto`, and the two prose
   containers took the cap up instead.

   1160px UNDERSTATED IT ANYWAY. `.profile` is inside `.container`, whose
   `--page-col` stops being a fixed 1160 above 1400px — it becomes
   `min(catalogue-col + 2 * page-pad, 88vw)`, which is roughly 1620px on a 1920
   panel and 2227px on a 3200 one. A measure written for the old sentence would
   have been about twice as wrong as the sentence claimed on the conference
   screens the ≥1900px blocks were written for. */

.profile {
  padding: 0 0 80px;
}

/* Same size as .features h2, so the two closing bands read as siblings. */
.profile-head {
  margin: 0 0 24px;
  font-size: 1.6rem;
  text-align: center;
}

/* Full container width. `.disclosure-toggle` is already `width: 100%`, so this
   grid is the only thing that was ever holding the four buttons in — no shared
   rule had to move for them to span the band. */
.profile-list {
  display: grid;
  gap: 10px;
}

/* Aligns the opened text with the label on the toggle above it (which carries
   15px of its own padding). Scoped to this band: the rail and the category index
   want the near-flush 2px the shared rule gives them, because what they open is
   a row of chips, not a paragraph. */
.profile-list .disclosure-region {
  padding: 14px 15px 18px;
}

/* THE MEASURE, now that the rows no longer carry it. These two are the only
   block-level children a profile disclosure-region ever has — see the rows table
   in <Profile /> — so capping the pair caps every word in the band.

   49.375rem is 790px at the 16px default root, and 790 rather than 820 is the
   number that changes nothing: the region above pads 15px each side, so the
   prose box inside the old 820px column measured 790px. Rounding it up to 820
   here would have quietly widened the measure by 30px while claiming to preserve
   it.

   rem rather than the px ladder .hero and .modal-card step through at 1900,
   2400 and 3200px, and the difference is what the number MEANS. Those are boxes;
   this is a measure, which is a count of characters and only incidentally a
   count of pixels. The same blocks that widen the page also lift the root font
   to 17, 19 and 22px, so a px cap would hold ~95 characters at a desk and shrink
   to ~69 on a 3200px panel — the one screen where the band is most obviously
   too wide. In rem it is 95 characters at every step, and a fifth breakpoint
   needs no line here at all.

   No `margin-inline: auto`. Left-aligned is the point: the text sits under the
   heading of the toggle that opened it, which is what the region's own padding
   is for, and centring it in a 1112px region would strand it from that heading
   at every width above 820px. */
.profile-list .profile-text,
.profile-list .profile-values {
  max-width: 49.375rem;
}

/* 0.92rem/1.75 rather than the 0.9/1.6 a feature card takes: these are 130-word
   statements read straight through, not captions. */
.profile-text p {
  margin: 0 0 13px;
  color: var(--text-dim);
  font-size: 0.92rem;
  line-height: 1.75;
}

.profile-text p:last-child { margin-bottom: 0; }

/* The sign-off. Full ink above a rule — the one line of the block written to be
   remembered rather than checked. Two classes deep to beat `.profile-text p`. */
.profile-text .profile-closer {
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
  color: var(--text);
  font-weight: 600;
}

/* A <ul>, so the list resets here. Six is the whole set and it is read as one. */
.profile-values {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 15px;
}

.profile-values h3 {
  margin: 0 0 4px;
  font-size: 0.95rem;
}

.profile-values p {
  margin: 0;
  color: var(--text-dim);
  font-size: 0.88rem;
  line-height: 1.65;
}

/* ==== END: Company profile ================================================= */


/* ==== BEGIN: Footer ======================================================== */
/* Same sage as the navbar. Text is full --text, not --text-dim, which only
   reaches 1.9:1 on this band. No top border: the cream-to-sage edge is already
   a hard colour change.

   Four stacked, centred rows — blurb, actions, channels, copyright — which is
   what gives the band its height. Nothing here is a fixed height: the padding
   and the row gaps set it, so the band grows with its content instead of
   clipping it. */

.footer-wrap {
  background: var(--surface-footer);
  padding: 2.875rem 0 1.625rem;
  color: var(--text);
  font-size: 0.85rem;
  text-align: center;
}

.footer-inner {
  display: flex;
  flex-direction: column;
  gap: 26px;
}

/* Margin only. The heading's own font-size used to be 1.3rem and is deliberately
   NOT set here any more: the <h2> is now the lockup, whose two lines carry
   explicit rem sizes, so a font-size on the parent would style nothing and read
   as though it still did. .footer-brand below is where the size lives. */
.footer-lead h2 {
  margin: 0 0 8px;
}

/* The footer's copy of the navbar lockup — the NAME only. Everything structural
   — the flex row, the stacked column, the ink — comes from .brand and .brand-*
   above, so this block is only what the SIGN-OFF does differently: centred
   rather than flush left, and one size step up, because here the name IS the
   heading instead of sitting beside a search field.

   No monogram here: <Wordmark mark={false} /> in the footer, by request. That is
   why there is no `.footer-brand .brand-mark` override any more — it used to
   scale the plate to 44px and hold its 7.4:1 / 9.0:1, and with no plate to match
   it would be a rule selecting nothing. The navbar plate is untouched.

   No `gap` either: .brand sets 0.9375rem for the plate-to-name join, and with the
   plate gone this row has ONE child, so a gap here would separate nothing while
   reading as though it still did. Nothing here touches colour — this band is the
   same sage as the navbar's, so the 5.4:1 wordmark carries over. */
.footer-brand {
  justify-content: center;
}

.footer-brand .brand-line-a { font-size: 1.45rem; }
.footer-brand .brand-line-b { font-size: 0.66rem; }

.footer-lead p {
  max-width: 520px;
  margin: 0 auto;
  line-height: 1.6;
}

/* Under the wordmark, above the blurb — the lockup line. Two classes deep for
   the same reason as .hero .hero-slogan: `.footer-lead p` would otherwise set
   the margin and it would sit flush against the sentence below it. */
.footer-lead .footer-slogan {
  margin: 0 auto 8px;
  font-weight: 700;
  letter-spacing: 0.3px;
}

/* The row that holds Contacts, and the row that holds Back to top - one button
   each, in two places in the footer. Both are taller than a navbar button and
   given a minimum width, so neither ends up sized by the length of its label.
   (There was an About button here too until the company profile absorbed it.) */
.footer-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
}

.footer-action {
  min-width: 9.75rem;
  min-height: 3.25rem;
  gap: 0.375rem;
  font-size: 0.95rem;
}

/* Back to top, and only that one - Contacts above it shares `.footer-action`
   and stays the plain card button.

   GEOMETRY ONLY as of 2026-08-04. Every colour this rule used to set - a #0a63e0
   azure fill, a white label, a white 2px ring and a blue-tinted drop - now comes
   from `.btn-sunset`, which the button carries as `variant="sunset"`. They were
   not left here to be overridden: both selectors are one class deep, this block
   is ~250 lines later in the file, so source order would have handed every one
   of them back to the blue and the variant would have done nothing visible.
   Anything colour-shaped for this button belongs in .btn-sunset now.

   What the blue knew, kept because the sage band still demands it: no flat fill
   is separable from #6f9f6f on its own - the azure measured 1.77:1 and even a
   near-navy #123fbf only reached 2.76:1 - so a ring is mandatory here rather
   than decorative. .btn-sunset draws one in ink for exactly that reason.

   Wider and heavier than its sibling on purpose: the user asked for highly
   visible, and on a band this saturated size does as much work as hue. */
.footer-to-top {
  min-width: 11.875rem;
  font-size: 1rem;
  letter-spacing: 0.2px;
}

/* The hover lift and the focus ring moved to `.btn-sunset` too, and for a reason
   worth keeping: the blue could only be brightened by hand, because every
   lighter blue tried dropped its WHITE label under AA (#1a73f0 is 4.42:1,
   #2b7ef7 is 3.87:1) - which is also why it could not use
   `filter: brightness()`, a filter that would have walked the label under the
   line silently. A dark label inverts that constraint: brightening the fill only
   raises its contrast, so the variant is free to brighten on hover. The one
   thing that stayed behind is the arrow below, which no other caller has. */

/* The arrow is the glyph that says which way, so it gets to be bigger than the
   label and to move with the lift. It carries no class - it is the button's
   aria-hidden span - so it is selected by that rather than by adding one. */
.footer-to-top span[aria-hidden="true"] {
  font-size: 1.15rem;
  line-height: 1;
  transition: transform 0.15s ease;
}

@media (hover: hover) {
.footer-to-top:hover span[aria-hidden="true"] { transform: translateY(-2px); }
}

.footer-reach h3 {
  margin: 0 0 12px;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 1.2px;
  text-transform: uppercase;
}

/* auto-fit rather than a media query: the five channels sit in one row on a
   desktop and fold to two, then one, on the way down to 320px. */
.footer-channels {
  list-style: none;
  max-width: 660px;
  margin: 0 auto;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
  gap: 10px;
}

/* Translucent white rather than --card: on sage it stays part of the band until
   hovered, when it lifts to the full card surface. The alpha went from 0.16 to
   0.55 when the glyphs became coloured marks — a green or a plum ink needs a
   pale ground under it, and at 0.16 the sage was still showing through enough
   to drag every one of them down. */
.footer-channel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  width: 100%;
  height: 100%;
  padding: 12px 10px;
  border-radius: 12px;
  border: 1px solid rgba(255,255,255,0.6);
  background: rgba(255,255,255,0.55);
  color: var(--text);
  font-family: inherit;
  transition: all 0.15s ease;
}

@media (hover: hover) {
.footer-channel:hover {
  background: var(--card);
  border-color: var(--accent);
}
}

.footer-channel-glyph { font-size: 1.45rem; line-height: 1; }

.footer-channel-label { font-size: 0.86rem; font-weight: 600; }

/* The handle can be a full address, and the tile is ~118px at its narrowest. */
.footer-channel-handle {
  font-size: 0.72rem;
  line-height: 1.45;
  opacity: 0.8;
  overflow-wrap: anywhere;
}

.footer-note {
  margin: 12px 0 0;
  font-size: 0.8rem;
  line-height: 1.5;
  opacity: 0.85;
}

/* The copyright line and the policy links share this row, centred, and wrap to
   two lines rather than shrinking — on a phone the links go under the notice,
   which is the order they are read in anyway. */
/* line-height on the ROW, so the copyright notice inside it inherits one. That
   notice is a bare <span> with no class of its own and it wraps to two lines
   below about 400px — measured at 320, 360, 375 and 390 — where `normal` left
   it at about 1.2. Set here rather than by giving the span a class, because a
   class invented for one declaration is a class the next reader has to look up;
   the row has exactly one text child and this is what it should read at. */
.footer-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 8px 18px;
  padding-top: 20px;
  line-height: 1.5;
  border-top: 1px solid rgba(255,255,255,0.35);
}

.footer-legal {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px 18px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* A button that looks like a link, because that is exactly what it is: it opens
   the document in a dialog on this page, and there is no URL behind it and no
   file to save. Styled from --text, the ink the whole band uses — 4.8:1 on the
   sage, so it clears AA as body text — and the hover goes DARKER (--brand-ink)
   rather than to an accent, which would have to be re-measured against sage. */
.footer-legal-link {
  padding: 0;
  border: none;
  background: none;
  color: var(--text);
  font: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

@media (hover: hover) {
.footer-legal-link:hover {
  color: var(--brand-ink);
  text-decoration-thickness: 2px;
}
}

/* ==== END: Footer ========================================================== */


/* ==== BEGIN: Contact panel ================================================= */
/* The composer renders inside the modal, so it supplies the padding the
   modal-card has none of — with the same top clearance as .auth-card, which the
   close button sits in.

   The About dialog that used to share these rules is gone, along with the company
   profile it carried — the info-panel block and everything under it went with it,
   and this is the only panel left. (Written without a leading dot on purpose: the
   class-name audit reads any `.name` in this file as a live selector, including
   inside a comment.) */

.contact-panel {
  padding: 46px 24px 24px;
}
/* Channel switcher inside the composer. Same five channels as the footer, as
   pills, because here they are a choice rather than five ways in. */
.contact-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 18px;
}

/* inline-flex, not the default inline flow: the channel icon is a block-level
   <svg>, and in flow it would sit on a line of its own above the label. */
.contact-tab {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.375rem;
  flex: 1 1 auto;
  padding: 0.5rem 0.75rem;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--bg-soft);
  color: var(--text-dim);
  font-family: inherit;
  font-size: 0.8rem;
  font-weight: 600;
  transition: all 0.15s ease;
}

@media (hover: hover) {
.contact-tab:hover { border-color: var(--accent); }
}

.contact-tab.active {
  background: var(--card);
  border-color: var(--accent);
  color: var(--text);
}

/* Who the message is going to and who it is from — both fixed by this point,
   so they are stated rather than asked for.

   A BLOCK RATHER THAN A PARAGRAPH since 2026-08-31. It was a run-on sentence of
   three facts separated by middle dots, and it became five when the email and
   the premises were added; five run together is a paragraph nobody reads at the
   top of a form. */
.contact-route {
  margin: 0 0 14px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 9px;
  background: var(--bg-soft);
  color: var(--text-dim);
  font-size: 0.82rem;
  line-height: 1.6;
}

.contact-route-to {
  margin: 0 0 8px;
  padding-bottom: 8px;
  border-bottom: 1px solid var(--border);
}

/* The customer's own details, filled in from their profile. Two columns, labels
   dim — the same shape the admin console shows the same facts in, so the two
   ends of one conversation look like one thing. */
.contact-from {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 2px 12px;
  margin: 0;
}

.contact-from dt { font-weight: 600; }
.contact-from dd { margin: 0; color: var(--text); }

/* A field the profile does not carry. Italic and dim rather than red: nothing
   is broken and nothing is being refused — it is a fact about the account, and
   on the email channel the sentence beside it says what follows from it. */
.contact-missing {
  font-style: italic;
  color: var(--text-dim);
}

/* What the button will do, under the button. Same size and ink as .cart-note,
   which does the same job under Place order. */
.contact-note {
  margin: 10px 0 0;
  color: var(--text-dim);
  font-size: 0.78rem;
  line-height: 1.55;
}

/* "WhatsApp Not Found!" — the in-page version of the alert that was asked for.
   Rendered here rather than thrown as window.alert() so it can say what to do
   next and so it does not land on top of the app the customer may be waiting
   for. --danger ink on a wash of it, the same language .message-delivery.is-
   failed speaks in the admin console. */
.contact-missing-app {
  margin: 10px 0 0;
  padding: 10px 12px;
  border: 1px solid rgba(192,57,43,0.35);
  border-radius: 9px;
  background: rgba(192,57,43,0.07);
  color: var(--danger);
  font-size: 0.8rem;
  line-height: 1.55;
}

/* The way out of the composer without sending. A quiet button under the loud
   one — it is an escape, not an action, and the primary here is Send. The gap
   is .btn's own margin; only the separation from the note above is set. */
.contact-back { margin-top: 10px; }

/* The reference, under the confirmation's sentence. Small, because it matters
   only when somebody rings up about it — and then it matters completely. */
.contact-reference {
  margin: 10px 0 0;
  color: var(--text-dim);
  font-size: 0.78rem;
}

.contact-form { margin: 0; }

/* The confirmation reuses .auth-success, which already carries its own top
   padding; inside this panel the modal-close clearance is above it. */
.contact-panel .auth-success { padding-top: 0; }

/* ==== END: Contact panel =================================================== */


/* ==== BEGIN: Legal documents =============================================== */
/* The Terms of Sale and the Privacy Policy, rendered from legal-content.js into
   the wide modal. Like the contact panel, this supplies its own padding — the
   modal card has none — with the same 46px of top clearance that keeps a
   heading out from under the absolutely-positioned close button. */

.legal-doc {
  padding: 46px 26px 24px;
}

.legal-head {
  padding-bottom: 14px;
  border-bottom: 2px solid var(--border);
}

.legal-org {
  margin: 0 0 4px;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.09em;
  color: var(--accent-2);
}

.legal-title {
  margin: 0;
  font-size: 1.1rem;
  line-height: 1.35;
  color: var(--text);
}

/* The scroll is on the body, not on the modal, so the document's own title
   stays put while four hundred clauses move under it — a reader three
   schedules into the Terms can still see which of the two they are in.
   Same split, for the same reason, as .leaflet-body. */
.legal-body {
  max-height: 64vh;
  overflow-y: auto;
  margin-top: 16px;
  padding-right: 0.625rem;
  font-size: 0.84rem;
  line-height: 1.65;
  color: var(--text);
}

/* Three levels, all below the document title's <h2>. A SCHEDULE gets the rule
   above it because in the source it starts a new page, and a page break is the
   one piece of the printed document's structure this rendering can carry. */
.legal-h1 {
  margin: 30px 0 12px;
  padding-top: 1rem;
  border-top: 2px solid var(--border);
  font-size: 0.95rem;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--accent-2);
}

.legal-h2 {
  margin: 22px 0 8px;
  font-size: 0.86rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--text);
}

.legal-h3 {
  margin: 16px 0 6px;
  font-size: 0.82rem;
  font-weight: 700;
  color: var(--text-dim);
}

/* First heading of a document has nothing above it to be spaced from, and the
   rule .legal-h1 draws would double the one under the title block. */
.legal-body > :first-child {
  margin-top: 0;
  padding-top: 0;
  border-top: none;
}

.legal-p {
  margin: 0 0 10px;
}

/* Hanging indent for the lettered sub-clauses these documents run on: the
   "(a)" sits in the gutter and the wrapped lines align under the words, not
   under the letter. padding-left and text-indent are equal and opposite —
   changing one without the other breaks the alignment silently. */
.legal-li {
  margin: 0 0 7px 18px;
  padding-left: 24px;
  text-indent: -24px;
}

.legal-bullets {
  margin: 0 0 10px;
  padding-left: 38px;
  list-style: disc;
}

.legal-bullets li {
  margin-bottom: 5px;
}

/* Schedule 1's entitlement matrix is seven columns. It fits at 820px, but a
   phone is not 820px and a table that cannot shrink would otherwise push the
   whole dialog sideways — so the overflow is trapped here, on the table's own
   wrapper, and the document keeps its width. */
.legal-table-scroll {
  overflow-x: auto;
  margin: 0 0 16px;
}

.legal-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.78rem;
  line-height: 1.5;
}

.legal-table th,
.legal-table td {
  padding: 7px 10px;
  border: 1px solid var(--border);
  text-align: left;
  vertical-align: top;
}

.legal-table th {
  background: var(--bg-soft);
  font-weight: 700;
  color: var(--accent-2);
}

.legal-absent {
  margin: 0;
  padding: 0.875rem 1rem;
  border: 1px solid var(--border);
  border-left: 4px solid var(--danger);
  border-radius: 8px;
  background: var(--bg-soft);
  font-size: 0.84rem;
  line-height: 1.6;
}

/* Printing. The key handler in <LegalDocument /> catches Ctrl/Cmd+P and
   Ctrl/Cmd+S; it cannot see the browser's own File > Print, so the text is
   removed from the printed page here as well and the page says why it is
   empty. Deterrents, not enforcement — the point is only that the site does
   not hand out its terms of trade as something to file away, since the copy on
   this page is the one that is current. */
@media print {
  .legal-body { display: none !important; }

  .legal-title::after {
    display: block;
    margin-top: 10px;
    font-size: 0.9rem;
    font-weight: 700;
    content: "This document is published on the Savannah Pharmaceuticals website and is read there. It is not issued in printed or downloadable form.";
  }
}

/* ==== END: Legal documents ================================================= */


/* ==== BEGIN: Responsive breakpoints ======================================== */
/* Base rules above are the ~1024px-and-up desktop layout; each block below
   overrides it going down.
   Note --nav-h is not set per breakpoint: useFixedNavHeight measures the real
   navbar height at every width, including when the search bar wraps below.

   Verified 320px → 5120px with no horizontal overflow, re-measured 2026-08-31
   after the page column below replaced the old container ladder.

   A NOTE ON HOW TO MEASURE THAT, because it was briefly recorded here as a real
   fault and was not one. The harness that walks these widths injects its own
   `<pre>` of results into the page, and a `<pre>` is `white-space: pre` — so the
   diagnostic text itself set the document's scrollWidth to 386–393px and every
   viewport narrower than that duly "overflowed". The storefront was clean
   throughout. **Measure overflow against an UNINSTRUMENTED copy of the page**,
   or put the readout somewhere that cannot widen the document. */

/* -- Very large screens (wide monitors, >=1600px) -------------------------- */
/* The hero only. The container moved out of this block when the page column was
   derived from the catalogue — see below — and --admin-col is what keeps the
   dashboards on the width they had. */
@media (min-width: 1600px) {
  :root { --admin-col: 1360px; }
  .hero { max-width: 800px; }
  .hero h1 { font-size: 3.2rem; }
}

/* -- The page column on a wide screen (>=1400px) --------------------------
   FOUR CARDS TO A ROW, AND ONE COLUMN FOR THE WHOLE PAGE. Reported on
   2026-08-30 against a wide TV and restated for monitors: no space around the
   cards, and margins that do not balance.

   THREE FAULTS, all measured, and this block answers all three.

   (1) THE BREAKPOINT NEVER FIRED ON THE SCREEN IT WAS WRITTEN FOR. It was
   `min-width: 1600px`. A nominal 1600px monitor reports 1585 once the scrollbar
   is off it, so it fell to the 1160px base container: four 267.5px cards, 14px
   gutters and 236.5px of margin each side — 14.9% of the screen dead on either
   flank, on a screen wide enough to want the opposite. 1536, 1570 and 1585 all
   landed there. The file already documents this trap for 1900/2400/3200 ("a
   1920px panel measures 1905") and then set 1600 on the nominal figure anyway.
   The floor is now 1400: above every common laptop (1280, 1366) and below every
   monitor that was falling through (1440, 1512, 1536, 1585, 1680, 1728).

   (2) THE PAGE HAD THREE LEFT EDGES ABOVE 2400px. Measured at 2530: the navbar
   and footer started at 245, the showcase and company strip at 285, and the
   first card at 379 — because the cap sat on .products alone while every
   sibling took the container. At 3810 the catalogue was inset 298px from the
   section directly above it. That is what "the margins do not balance" is: not
   the size of the white space but three different amounts of it on one page.
   THE CAP IS THEREFORE ON .container, NOT ON .products, and the derivation
   runs the other way round — the container is now whatever four cards want,
   so the navbar, hero, showcase, company strip, catalogue and footer share one
   column and one pair of margins. One edge, at every width.

   (3) THE MARGINS SAWTOOTHED. A fixed container cap inside a band means the
   margin grows across the band and drops at the next breakpoint: 9.0% at 1600,
   15.3% at 1890, 9.7% at 1905, 15.0% at 2530. The `88vw` term is the floor
   that removes it — below the cap the column is 88% of the screen and the
   margin holds near 6% a side; only where --card-cap binds does it grow, and
   that growth is the arithmetic of a four-column maximum, not a defect.

   min() and vw rather than a px ladder, and the vw is deliberate twice over:
   100vw does not change when useScrollLock takes the document scrollbar away,
   so the column cannot jump the width of a scrollbar when a modal opens — the
   thing --scrollbar-gutter exists to stop the navbar doing.

   WHAT IS DELIBERATELY NOT HERE: --card-cap, --card-gap and --catalogue-col.
   They are on :root so one edit re-derives the container, the grid and the
   gutter together. Change the card and the page follows it. */
@media (min-width: 1400px) {
  :root {
    --page-col: min(calc(var(--catalogue-col) + 2 * var(--page-pad)), 88vw);
  }

  /* repeat(4) rather than auto-fill, and it keeps auto-fill's ONE property that
     mattered — a filter narrowed to two results leaves two cards their size
     and two empty tracks, rather than stretching them across the row. */
  .product-grid {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: var(--card-gap);
  }
}

/* -- Conference screens and large panels (>=1900px) ------------------------
   Above 1600px the layout used to stop dead: .container held at 1360px and the
   grid at five 245px columns, so a 2560px panel showed the storefront across
   53% of its width and a 3840px one across 35.6% — measured, both — with 16px
   body text either way. On a desk that is merely wasteful. On a room display it
   is the whole problem, because what makes a catalogue readable from three
   metres is BIGGER type and BIGGER cards, not more of them.

   So each block below does two things together, and the pairing is the point:
   it lifts the root font size AND the page gutter, and the column widens with
   them on its own — --card-cap is in rem, so a bigger root is a bigger card is
   a wider page. Lifting the root without widening would only push the reading
   distance further out; widening without lifting would only fit more of the
   same small cards in. NEITHER IS SET AS A px CONTAINER WIDTH HERE ANY MORE:
   that ladder became --admin-col, which now only serves #/admin.

   The root size is a PERCENTAGE, not a px value. Every font-size in this file
   is in rem — 196 of them, and not one in px — so a percentage here scales all
   type at once and still composes with a base size the reader has set in their
   own browser. A px value would silently overrule that, which is why it is not
   used even though it would read more plainly.

   SPACING. The line that used to sit here said the card's own padding "was
   measured at each step and still sits right". IT WAS NOT AND IT DID NOT, and
   the measurement that proved otherwise is worth keeping: padding is px and
   type is rem, so at a 22px root EVERY px-padded box holding rem text came out
   at exactly 16/22 of its designed padding-to-text ratio — 27% tighter, on the
   button, the tab, the disclosure toggle, the navbar, the card plate, the card
   body and the spec block alike. The worst of it was the wordmark: .brand-mark
   is a plate, and a fixed 38px plate whose monogram grew from 15.7px to 21.6px
   went from 41% full to 57% full while the name beside it outgrew the plate
   entirely (the lockup measured 38px tall at 1366 and 43.8px at 3840).

   So the rule is now: A COMPONENT WHOSE TEXT IS rem CARRIES ITS OWN GEOMETRY
   IN rem TOO. Padding, gaps, radii and fixed plate sizes on .navbar, .brand,
   .brand-mark, .btn, .showcase-tab, .disclosure-toggle, .product-plate,
   .product-info, .product-specs and .qty-stepper are expressed as the same
   lengths divided by 16, so they render identically at a 16px root and stay in
   proportion above it. Nothing needs lifting per breakpoint any more, and the
   three blocks below carry no padding overrides for them.

   WHAT DELIBERATELY STAYS px: hairline borders (a 1.375px border is a blurry
   one), the 44px touch minimums in the any-pointer block (44px is a finger, not
   a font), .zoom-img's 1000px cap (that is PRODUCT_IMAGE_BOX, a real image
   size), and the breakpoint values themselves. The page gutter keeps its
   per-breakpoint lift because it spaces the page against the SCREEN EDGE rather
   than text against its own box, and it was chosen by eye. It is --page-pad as
   of 2026-08-30 rather than a padding declaration per block, because the page
   column adds it twice and the two would otherwise drift.

   THE GRID GAP USED TO BE ON THAT LIST and no longer is: the catalogue column
   block above states it once in rem, and these three blocks lift it by lifting
   the root. Its three px overrides lived here until 2026-08-29.

   The two modal widths move with the type for the same reason the container
   does: they bound TEXT, and a box held at 820px while its type grows 37% is a
   box that holds a third fewer words to the line. The zoom layer deliberately
   does NOT move. .zoom-img is capped at min(92vw, 1000px) because 1000px is the
   square PRODUCT_IMAGE_BOX resizes every pack shot to, so a wider cap would be
   upscaling a photograph rather than showing more of it.

   The breakpoints are 1900/2400/3200 rather than 1920/2560/3840 because a
   screen of that nominal width reports a viewport about 15px narrower once the
   scrollbar is taken off it. A 1920px panel measures 1905, so a `min-width:
   1920px` block would never fire on the exact screen it was written for. */
/* The container's own max-width left these three blocks when the page column
   was derived from the catalogue: --page-col above 1400px already carries it,
   and the figures these blocks used to set are now --admin-col, which is the
   one surface that still wants the whole band. What stays here is the root
   font, the gutter (--page-pad, which .container reads for its padding) and
   the boxes that bound TEXT rather than the page. */
@media (min-width: 1900px) {
  html { font-size: 106.25%; }               /* 17px against a 16px default */
  :root { --page-pad: 32px; --admin-col: 1600px; }
  .hero { max-width: 900px; }
  .modal-card { max-width: 470px; }
  .modal-card-wide { max-width: 870px; }
}

@media (min-width: 2400px) {
  html { font-size: 118.75%; }               /* 19px */
  :root { --page-pad: 40px; --admin-col: 2040px; }
  .hero { max-width: 1040px; }
  .modal-card { max-width: 520px; }
  .modal-card-wide { max-width: 975px; }
}

@media (min-width: 3200px) {
  html { font-size: 137.5%; }                /* 22px */
  :root { --page-pad: 56px; --admin-col: 2760px; }
  .hero { max-width: 1200px; }
  .modal-card { max-width: 605px; }
  .modal-card-wide { max-width: 1130px; }
}

/* -- Medium screens (tablets, <=1023px) ----------------------------------- */
@media (max-width: 1023px) {
  /* NOTHING here touches the wordmark, and that is the notable part: this block
     used to hide half the company name from 1023px down, because the old inline
     wordmark cost 286px and left a company-page search field 0px of usable width
     at 768px. Stacking it fixed the cause -- the lockup now measures 194px WITH
     "Limited", and the same 768px company page leaves the search field 251px
     (measured, both). There is no longer a width to buy, so the truncation is
     gone rather than merely moved, and the bar says the full legal name at every
     width down to 479px. Do not reintroduce a .brand rule here without measuring
     that field first. */
  .hero { padding: 44px 22px 44px; }
  .hero h1 { font-size: 2.4rem; }
  .product-grid { grid-template-columns: repeat(auto-fill, minmax(205px, 1fr)); }
}

/* -- Small screens (large phones, <=767px) --------------------------------
   The search bar drops to its own row below the brand and actions, which is
   why the navbar height has to be measured rather than hardcoded.
   Keep this width in sync with SMALL_SCREEN_QUERY in app.jsx / index.html. */
@media (max-width: 767px) {
  .navbar { flex-wrap: wrap; }
  .nav-search { order: 3; flex-basis: 100%; max-width: none; }

  /* The crumb pushes the navbar onto a *third* row here (measured: 128px → 178px),
     and it is redundant — the company hero immediately below names the company.
     Hiding it holds the navbar at 111.3px at 390px and 320px alike. */
  .nav-crumb { display: none; }

  .hero h1 { font-size: 2rem; }
  .hero-actions { flex-direction: column; align-items: stretch; gap: 10px; }
  .hero p { font-size: 1rem; }

  /* No opacity override here: the field now crosses the copy at every width, so
     the one value in the base rule is already the measured ceiling. */
  .hero-banner { border-radius: 20px; }

  .toolbar { align-items: flex-start; flex-direction: column; }
  .toolbar-controls { width: 100%; }
  .select-wrap { flex: 1; }
  .select-wrap select { width: 100%; }

  /* Card internals are NOT tuned here — .product-card is a query container, so
     the plate, mark and spec rows break on card width in the .is-narrow block
     above. Doing it by viewport shrank the mark on the wide single-column card
     at 375px while leaving the cramped 3-up cards at 700px untouched. */
  .product-grid { grid-template-columns: repeat(auto-fill, minmax(185px, 1fr)); gap: 12px; }

  /* Every hero treatment collapses to one column. The banner's photo tile stops
     being a far-edge item and simply stacks under the copy — `space-between` has
     nothing to space out in a column, and `align-items: flex-start` is what
     stops it stretching to the full width. */
  .company-hero.layout-split,
  .company-hero.layout-banner,
  .company-hero.layout-rule {
    flex-direction: column;
    align-items: flex-start;
    gap: 1.125rem;
  }

  .company-hero.layout-panel { grid-template-columns: 1fr; }
  .company-hero-mark { font-size: calc(2rem * var(--mk-scale)); }

  .company-hero-plate { width: 92px; height: 92px; }
  .company-hero-panel .company-hero-plate { width: 76px; height: 76px; }

  .company-hero.layout-rule { padding: 24px 20px 24px 0; }
  .company-hero.layout-rule .company-hero-text { padding-left: 18px; }
  /* Stretched, so `max-width` has to lift with it or the stamp stays 230px wide
     in a column that is asking it to fill the band. */
  .company-hero-stamp { align-self: stretch; max-width: none; margin-left: 18px; }
  .company-hero-panel { min-width: 0; }

  .category-index { max-width: none; }

  /* Two equal halves rather than a wrapping pill row — "Therapeutic
     categories" is too long to sit beside its sibling at this width. */
  .showcase-tabs { display: flex; width: 100%; }
  .showcase-tab { flex: 1; padding: 8px 10px; font-size: 0.8rem; }

  .showcase-spot { flex-direction: column; align-items: flex-start; gap: 12px; }
  .showcase-spot-count { margin-left: 0; text-align: left; }

  .feature-grid { grid-template-columns: 1fr; gap: 12px; }


  /* The footer actions go full width — side by side at this width they are
     narrower than their own minimum. This `min-width: 0` also has to sit after
     .footer-to-top's 190px rather than before it: same specificity, so source
     order is the whole of the cascade here, and the wrong order would leave the
     blue button at a fixed 190px inside a stretched column. */
  .footer-actions { flex-direction: column; align-items: stretch; }
  .footer-action { min-width: 0; }
}

/* -- Very small screens (small phones, <=479px) --------------------------- */
@media (max-width: 479px) {
  /* The gutter, not a .container rule, so it stays the single source the page
     column reads. Nothing above 1400px is in play here, so this cannot collide
     with the derived column. */
  :root { --page-pad: 16px; }
  .navbar { padding: 12px 0; gap: 12px; }

  /* The lockup stays TWO LINES on a phone, whole legal name included. Collapsing
     it to a bare word here would make the smallest screen the only one that does
     not say what the company does, and it is not necessary: scaled down like
     this it measures 171px against the 348px row a 375px phone has, so it fits
     signed in as well as signed out. The 374px block at the end of this section
     is where it finally gives something up. */
  .brand { gap: 9px; }
  .brand-mark { width: 34px; height: 34px; border-radius: 10px; font-size: 0.9rem; }
  .brand-line-a { font-size: 1.05rem; }
  .brand-line-b { font-size: 0.5rem; letter-spacing: 0.1em; margin-right: -0.1em; }

  .nav-actions { gap: 8px; }
  .nav-actions .btn { padding: 8px 12px; font-size: 0.85rem; }

  .eyebrow { font-size: 0.7rem; }
  .hero { padding: 32px 18px 36px; }
  .hero-banner { margin-top: 12px; border-radius: 18px; }
  .hero h1 { font-size: 1.7rem; }
  .hero p { font-size: 0.95rem; }

  .company-strip { padding-bottom: 32px; }
  .company-strip h2 { font-size: 1.3rem; }
  .company-directory { grid-template-columns: 1fr; }

  .company-hero.layout-split,
  .company-hero.layout-stack,
  .company-hero.layout-panel,
  .company-hero.layout-banner { padding: 22px 18px; }

  /* Drops the wordmark small enough that break-word rarely has to fire — a
     display face broken mid-word is legible but ugly. */
  .company-hero-mark { font-size: calc(1.6rem * var(--mk-scale)); }

  .category-group-head h3 { font-size: 0.95rem; }
  .category-chip { padding: 6px 10px; font-size: 0.76rem; }

  .showcase { padding-bottom: 34px; }
  .showcase h2 { font-size: 1.3rem; }
  .showcase-spot { padding: 16px 18px; }
  .showcase-tile { padding: 7px 11px; font-size: 0.76rem; }

  .pager { gap: 6px; }
  .pager-num,
  .pager-step {
    min-width: 2.125rem;
    height: 2.125rem;
    padding: 0 0.5625rem;
    font-size: 0.82rem;
  }

  /* Single column: two columns at this width leaves ~137px per card, too
     narrow for the "Dissolution / 94% at 30 min" spec row to hold one line. */
  .product-grid { grid-template-columns: 1fr; }
  .docs-head { margin: -20px -16px 16px; padding: 18px 52px 14px 16px; }
  .features h2 { font-size: 1.35rem; }
  .feature-card { padding: 20px; }

  /* Still AA at this size — --hero-teal is measured for it. */
  .hero .hero-slogan { font-size: 1rem; }
  .profile-head { font-size: 1.35rem; }
  .profile-list .disclosure-region { padding: 12px 12px 16px; }


  .modal-overlay { padding: 12px; }
  .cart-panel { padding: 20px 16px 22px; }
  /* Same tightening as the cart's, and the same values: this is where the
     confirmation is actually read — the delete lives in an admin table that IS
     used from a phone. */
  .admin-confirm { padding: 20px 16px 22px; }
  .auth-card { padding: 22px 18px 20px; }
  .modal-card .auth-card { padding-top: 44px; }

  .contact-panel { padding: 44px 18px 20px; }

  /* The document keeps its 820px card on a desktop and simply fills a phone.
     The lettered sub-clauses lose their outer indent here — at 320px an 18px
     margin plus a 24px hanging indent leaves a clause seven words wide. */
  .legal-doc { padding: 44px 16px 20px; }
  .legal-li { margin-left: 0; }
  .legal-bullets { padding-left: 24px; }

  /* Two pills a row beats five squeezed onto one at 320px. */
  .contact-tab { flex: 1 1 40%; }

  /* THE RULE THAT USED TO SIT HERE hid the account name at this width, and it
     was the whole of the "blank white pill" reported on 2026-08-30: the name
     was the button's only content, so hiding it emptied the control rather than
     shrinking it. What it was buying — a navbar row that does not spill onto a
     third line — the initials plate buys outright, at 44px against the 176px
     the name cost. Nothing replaces it. */

  .footer-wrap { padding: 34px 0 22px; }
  .footer-inner { gap: 22px; }

  /* Was `.footer-lead h2 { font-size: 1.15rem }`, which stopped doing anything
     the moment the heading became the lockup — the two lines carry their own
     sizes. Scaled through .footer-brand instead, and it keeps the footer's step
     up over the navbar (which is at 34px / 1.05rem / 0.5rem at this width).

     The plate rule that used to sit here went with the plate itself — see
     .footer-brand in the main block. So did the `gap`, for the same reason it
     went there: one child, nothing to separate. */
  .footer-brand .brand-line-a { font-size: 1.2rem; }
  .footer-brand .brand-line-b { font-size: 0.56rem; }

  .feature-grid.collapsible .feature-toggle { padding: 14px 16px; }
  .feature-grid.collapsible .feature-card p { padding: 0 16px 16px; }
}

/* -- Narrow phones (<=374px) ----------------------------------------------- */
/* The one width that cannot carry the whole legal name, and the only thing this
   block does. "LIMITED" costs the second line 40px, and the failure it causes is
   not overflow -- the bar wraps instead, so nothing sticks out and the navbar
   just silently becomes THREE rows: measured 111px -> 157px at 320px. Growing a
   row is the regression, and a bar that measures fine is exactly how it would
   get missed, which is why the number is written down here.

   Why 374 and not 359. At 360px the full name does fit, but only just: 19px of
   slack signed out, and 10px signed in, because "Sign out" is 9px wider than
   "Sign in". 10px is not a margin, it is a coincidence -- one padding change in
   .nav-actions and 360px silently grows the third row. 375px keeps 25px signed
   in, which is a margin. So 360px Android loses "Limited" it could technically
   hold, on purpose.

   Nothing is actually lost: the lockup still reads "Savannah / PHARMACEUTICALS",
   and the link's aria-label announces the full legal name at every width. */
@media (max-width: 374px) {
  .brand-suffix { display: none; }
}


/* -- Touch devices: no control below 16px ---------------------------------
   iOS Safari ZOOMS THE WHOLE PAGE when it focuses a control whose font-size is
   under 16px, and it does not zoom back out afterwards -- the reader is left on
   a page they now have to pinch and pan to use. Every text control here was
   under that line, measured in the browser rather than read off this file: the
   nav search at 14.4px, the sign-up and checkout fields at 15.04px, the sort and
   filter selects at 13.6px, the cart quantity at 14.08px, and the two card
   fields inheriting 14.72px and 11.52px.

   THE FIX IS THE FONT SIZE, NOT `maximum-scale=1` ON THE VIEWPORT META. That
   line also stops the zoom -- by taking pinch-zoom away from everyone for the
   whole session, which is a plain accessibility failure. The meta tag in
   index.html stays exactly as it is.

   ANY-pointer, not pointer. `(pointer: coarse)` asks what the PRIMARY input is
   and reads false on an iPad with a keyboard case attached, where Safari still
   zooms -- the zoom follows the visual viewport, not the pointing device.
   `(any-pointer: coarse)` is true wherever a touchscreen exists at all, which is
   every device that can produce this bug. The cost is that a touchscreen Windows
   laptop also gets 16px controls: a slightly larger form field on a device with
   room for it, and the safe direction to err.

   max(1rem, 16px) rather than a bare 16px. The rem half keeps these controls in
   step with the root scaling in the blocks above, so a touch-enabled 4K panel
   gets 22px controls instead of 16px ones sitting oddly small beside 22px body
   text. The px half holds Safari's threshold even for a reader who has set their
   own base size below 16.

   The :not() pairs do two jobs at once. They exclude the controls that never
   trigger the zoom and should not grow -- checkboxes, radios, list boxes -- and
   they lift these selectors to (0,2,1), matching the class rules they have to
   beat: `.nav-search input` and `.field select` are both (0,1,1), and a bare
   `input` or `select` would lose to them. This block is last in the file, so an
   equal-specificity tie resolves here.

   The OTP field is restated because it is the one control ALREADY above the
   line, at 1.2rem. Without it this block would tie on specificity and quietly
   shrink a six-digit code from 19.2px to 16px -- a fix making something worse. */
@media (any-pointer: coarse) {
  input:not([type="checkbox"]):not([type="radio"]),
  select:not([multiple]):not([size]),
  textarea:not([readonly]):not([hidden]) {
    font-size: max(1rem, 16px);
  }

  .otp-step .field input { font-size: max(1.2rem, 19.2px); }
}

/* -- Touch devices: tap targets ------------------------------------------
   Measured at 375px, every control on the page that came in under 44x44:

     .doc-link            38x16 and 40x16, TWENTY-FOUR of them
     .qty-btn             38x48   (tall enough, six pixels too narrow)
     .pager-num           34x34 and 43x34
     .pager-step          56x34
     .showcase-tab        157x33
     .disclosure-toggle   328x39 and 328x40
     .footer-legal-link   79x18 and 80x18
     .btn                 50x34, 68x36, 70x30, 290x40
     the two nav selects  92x39 and 124x39

   TWO MORE FOUND 2026-08-31, both missed the first time because neither wears
   .btn and the audit was written around that class:

     .link-btn            52x18   (Sign out, and the auth card's switch links)
     .modal-close         30x30   (every dialog on the site)
     .admin-tab           115x36  (the three dashboard tabs)

   44px is the Apple HIG figure and WCAG 2.5.5 (AAA); WCAG 2.5.8 (AA) asks only
   24x24, which most of these already clear. The ones that do not clear even
   that are the two 16px-tall .doc-links on every card and the 18px footer
   links, which is why they are treated first and differently.

   .doc-link grows its HIT AREA and not its box. It is an inline text link in a
   dense card, twice per card; giving it 28px of real padding would add that to
   every card in a 1,563-row catalogue and push the fold up on every phone. A
   transparent ::after stretched past its edges gives the finger the 44px it
   needs and gives the layout nothing to reflow. The rule is safe to add because
   .doc-link had no ::after of its own — checked, not assumed — and the ±4px
   horizontal spread is measured against the real gap between the pair so the
   two hit areas cannot overlap.

   Everything else takes a min-height, because a taller button is simply a
   better button here and the rows they sit in have the room.

   .brand is deliberately LEFT at 171x34. It is the wordmark link, and 171px
   wide is not a target anyone misses; the only way to make it 44 tall is to
   grow the navbar on every phone, which trades a real cost for a theoretical
   one. It clears WCAG's AA figure five times over on width.

   Coarse pointers only, so the desktop layout keeps the proportions it was
   drawn with. Same query as the block above, and the same reasoning for it. */
@media (any-pointer: coarse) {
  .btn,
  .showcase-tab,
  .admin-tab,
  .disclosure-toggle,
  .footer-legal-link,
  .link-btn,
  .pager-step {
    min-height: 44px;
  }

  .pager-num {
    min-height: 44px;
    min-width: 44px;
  }

  .qty-btn { min-width: 44px; }

  .select-wrap select,
  .field select {
    min-height: 44px;
  }

  .doc-link { position: relative; }

  .doc-link::after {
    content: "";
    position: absolute;
    top: -14px;
    right: -4px;
    bottom: -14px;
    left: -4px;
  }

  /* The dialog's close button, measured 2026-08-31 at 30x30 and missed by the
     audit above because it is not a .btn. It takes the .doc-link treatment
     rather than a min-height for two reasons: it is a CIRCLE, and a 44px box
     with a 30px circle drawn in it is a 44px circle, not a bigger target on a
     smaller mark; and it is absolutely positioned in the card's top-right
     corner with nothing beside it, so an expanded hit area has nothing to
     collide with — unlike the stacked .link-btn pairs, which is why those take
     a real min-height above and grow the layout that separates them.
     -7px on each side is exactly 44x44. */
  .modal-close::after {
    content: "";
    position: absolute;
    top: -7px;
    right: -7px;
    bottom: -7px;
    left: -7px;
  }

  /* THREE ADMIN CONTROLS THE AUDIT ABOVE NEVER REACHED, found 2026-08-31 by
     driving the admin console itself rather than the shop — see
     messaging-sweep.html in the harness. Two of them predate the Messages tab
     and one arrived with it; all three sit in a console that IS used from a
     phone, which is why the sweep covers #/admin at 360.

     .admin-delete measured 54.5x26 and .admin-select's select 110x33. Neither
     matched anything in the list above: the select is inside `.field-select`,
     not `.field`, so `.field select` missed it by one class, and .admin-delete
     is a bare <button> with no .btn. Both take a min-height, because an admin
     row has the vertical room and a taller control there costs nothing.

     Those two numbers are the audit-day measurements and .admin-delete no
     longer matches its own: the base rule now carries roomier padding and a
     32px min-height, which this 44px overrides.

     READ THE GATE BEFORE ASSUMING THIS IS THE PHONE RULE. It is
     (any-pointer: coarse), NOT a width — so on any machine with a touch
     digitizer it wins at EVERY viewport width, mouse attached or not.
     Measured 2026-09-03 at 1280x900 on a touch laptop: .admin-delete
     computes min-height 44px there, not 32. The 32 is therefore a floor for
     mouse-only machines rather than the desktop half of a pair, and the
     padding above it is the part of that base rule a touch machine sees.

     .message-reply is different and takes the .doc-link treatment instead. It
     is an inline text link — "Reply by email", "WhatsApp" — sitting in a row
     beside a dropdown and a delete, and it measured 82.6x16, which fails even
     WCAG 2.5.8's AA floor of 24x24, let alone the 44 above. Giving it 14px of
     real vertical padding would push that action row down on every card in the
     inbox; a transparent ::after gives the finger 44px and the layout nothing.
     The ±4px horizontal spread matches .doc-link's and is measured against the
     10px flex gap in .admin-message-actions, so two adjacent hit areas cannot
     overlap. Safe to add because .message-reply had no ::after of its own —
     checked, not assumed. */
  .admin-delete { min-height: 44px; }

  .field-select.admin-select select { min-height: 44px; }

  .message-reply { position: relative; }

  .message-reply::after {
    content: "";
    position: absolute;
    top: -14px;
    right: -4px;
    bottom: -14px;
    left: -4px;
  }
}
/* ==== END: Responsive breakpoints ========================================== */
