/* Rock Technologies storefront — exploded-index / blueprint schematic
   (visual world imported from claude.ai/design, concept 2c: "exploded
   index, stripped" — see PRODUCT.md and the plan this was built from) */

@font-face {
  font-family: 'JetBrains Mono';
  src: url('../fonts/jetbrains-mono-variable.woff2') format('woff2');
  font-weight: 400 800;
  font-style: normal;
  font-display: swap;
}

:root {
  --bg: #121110;            /* page */
  --bg-deep: #0d0c0b;       /* header / footer */
  --card: #1a1917;          /* raised surfaces, tiles */
  --card-2: #1e1c19;
  --tile: #1a1917;          /* tiles product art sits on */
  --line: #26241f;          /* outer borders */
  --line-inner: #38342c;    /* inner/frame borders (the exploded panel) */
  --text: #f3f0e9;
  --text-soft: #a9a49a;
  --muted: #948e83;         /* WCAG AA (4.5:1+) against --bg and --card; was #7d786f */
  --good: #3fb950;
  --warn: #e3b341;
  --bad: #f85149;
  /* On-color text. These three were literals repeated through this file --
     #140a05 alone appeared six times -- while admin.css had defined and
     used them as tokens from the start. Same values, so nothing renders
     differently; they are named here so the storefront stops being the
     half where the palette is retyped by hand. See DESIGN.md's
     "On-Color Text": a solid accent or good fill needs near-black text,
     not white, against this warm dark ground. */
  --on-accent: #140a05;      /* text on any solid --accent fill */
  --on-good: #0c2412;        /* text on the --good "Added ✓" state */
  --on-good-strong: #06130a; /* the deeper variant, on the confirmation check */
  /* The panel gradient used by .totals and the exploded/confirmation
     panels: a barely-there vertical lift over --card, not a new surface
     color. Tokenised because the pair was retyped at three call sites. */
  --panel-grad-top: #1b1a17;
  --panel-grad-bottom: #141311;
  --tile-deep: #16150f;      /* the darker tile well behind product art */
  --shadow: 0 14px 40px rgba(0, 0, 0, .5);
  --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --font-mono: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --header-h: 63px;          /* static fallback for .site-header's real height;
                                 store.js measures and overwrites this on load/resize */
  /* Large-display hero boost: a single shared "how much extra room is there"
     value the exploded diagram's content sizing hooks into (see .exploded
     and its descendants below). 0 at/below 900px viewport height — so
     1440x900 and anything shorter renders byte-for-byte as it did before —
     then grows 1:1 with viewport height above that, capped at 700px (hit at
     1600px tall, e.g. 2560x1600). Threshold sits between 900 (1440x900,
     must stay untouched) and 1080 (1920x1080, must engage), so "≥1080p"
     reliably turns the boost on without disturbing anything at or below
     900. Every property that uses it multiplies this shared px value by
     its own small coefficient rather than hand-rolling its own svh clamp,
     so the whole diagram scales up as one proportional unit instead of
     drifting out of proportion piece by piece. */
  --hero-boost: clamp(0px, (100svh - 900px), 700px);
}
/* The static fallback above only holds at the header's default single-row
   height; below 560px .header-row wraps onto two rows (nav drops to its
   own line — see .main-nav { order: 3 } further down), roughly doubling
   the real height. Without this, a no-JS visit at a narrow width leaves
   --header-h under-counting the real header by ~40px, which the exact-
   height hero (below) would otherwise render 40px taller than the
   viewport. This is a width-keyed CSS approximation, not a replacement
   for the JS measurement — store.js still overwrites it immediately with
   the real offsetHeight whenever JS runs. */
@media (max-width: 560px) {
  :root { --header-h: 102px; }
}

/* --- accent themes -------------------------------------------------------
   Set via <html data-accent="…">, which comes from the ACCENT_THEME
   constant in rocktech/__init__.py — code-only, no runtime switcher (see
   README "Theme colors"). --accent-deep doubles as the admin dashboard's
   chart-bar color — re-validated per hue against the dark surface with the
   dataviz palette checker before use. */
:root, :root[data-accent="orange"] {
  --accent: #ff5a1f;         /* UI accent (buttons, links) */
  --accent-deep: #e84e0f;    /* validated chart mark / deep text */
  --accent-soft: #ff9d6b;    /* accent-tinted text on dark */
  --accent-hover: #ff6c38;   /* button hover */
  --accent-rgb: 255, 90, 31;
  --glow: rgba(var(--accent-rgb), .32);
}
:root[data-accent="blue"] {
  --accent: #5a9df5;
  --accent-deep: #3987e5;
  --accent-soft: #9dc9fb;
  --accent-hover: #74adf7;
  --accent-rgb: 90, 157, 245;
  --glow: rgba(var(--accent-rgb), .32);
}
:root[data-accent="green"] {
  --accent: #4ee05a;
  --accent-deep: #1f9e2c;
  --accent-soft: #a3e8a8;
  --accent-hover: #63e86e;
  --accent-rgb: 78, 224, 90;
  --glow: rgba(var(--accent-rgb), .32);
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; color-scheme: dark; }
/* Sticky footer. Short pages -- order confirmation, an empty cart, a 404 --
   have less content than one screen, and without this the footer sat
   directly under that content with the page background showing below it,
   which reads as the page having stopped early.

   Body is the flex column and <main> takes the slack (flex-grow), rather
   than the footer carrying `margin-top: auto`: auto would collapse the
   2rem breathing room the footer keeps on long pages, where there is no
   free space to distribute. Only three children are ever in flow here --
   .site-header, main, .site-footer -- since .flashes is position: fixed.

   svh, not vh, matches the hero's own unit (see .hero-full): vh is the
   LARGEST viewport height, so on mobile it would overshoot by the height
   of the browser chrome and put a scrollbar on a page that fits. */
body {
  margin: 0;
  min-height: 100vh;   /* fallback for browsers without svh */
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  font-family: var(--font);
  color: var(--text);
  background: var(--bg);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}
main { flex: 1 0 auto; }
img { max-width: 100%; display: block; }

/* One visible focus ring for every interactive element, rather than
   leaving it to the browser default -- which on a near-black ground is a
   thin dark halo that effectively disappears. Ported from admin.css, which
   had this from the start while the storefront did not: the half that
   handles customer money was the half without a focus strategy.
   :focus-visible, not :focus, so a mouse click on a button doesn't leave a
   ring behind it -- keyboard and programmatic focus still get one.
   Controls that are themselves an accent fill switch to a light ring so it
   cannot vanish into its own background. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Visually hidden but read aloud. Same definition admin.css already
   carries -- the storefront needed its own copy for the newsletter field's
   label and the spec table's caption, both of which a sighted visitor
   reads from context that a screen reader never receives. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* Wide content scrolls inside its own pane rather than pushing the page
   sideways. The storefront had no such guard anywhere, so one long spec
   value on a phone took the whole document with it. Negative margin plus
   matching padding lets a focus ring on a cell inside still show. */
.table-scroll { overflow-x: auto; margin: 0 -.2rem; padding: 0 .2rem; }
.btn:focus-visible,
.page-current:focus-visible,
.chip.is-active:focus-visible,
.spec-toggle:focus-visible {
  outline: 2px solid var(--text);
  outline-offset: 2px;
}
a { color: inherit; }
::selection { background: var(--accent); color: var(--on-accent); }
h1, h2, h3, h4 { line-height: 1.15; margin: 0 0 .5em; }
h1 { font-size: clamp(2rem, 5vw, 3.4rem); font-weight: 850; letter-spacing: -.035em; text-transform: uppercase; }
h2 { font-size: clamp(1.4rem, 3vw, 2rem); font-weight: 800; letter-spacing: -.01em; text-transform: uppercase; }
h3 { font-size: 1.15rem; font-weight: 750; }
h4 { font-size: .82rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.mono { font-family: var(--font-mono); }

.wrap { max-width: 1180px; margin: 0 auto; padding: 0 1.25rem; }

/* --- header ----------------------------------------------------------- */
.site-header {
  position: sticky; top: 0; z-index: 50;
  background: rgba(13, 12, 11, .92); backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--line);
}
.header-row { display: flex; align-items: center; gap: 1.6rem; padding: .8rem 1.25rem; }
.logo img { height: 30px; width: auto; filter: invert(1); }
.main-nav { display: flex; gap: 1.25rem; }
.main-nav a {
  text-decoration: none; font-weight: 600; font-size: .92rem;
  text-transform: uppercase; letter-spacing: .04em; color: var(--text-soft);
  padding: .25rem 0; border-bottom: 2px solid transparent;
}
.main-nav a:hover { color: var(--text); border-bottom-color: var(--accent); }

/* Shop dropdown: pure CSS hover. The menu sits flush against the trigger
   (top: 100%, no margin) so there's no dead gap for the pointer to cross —
   :hover on .nav-dropdown holds continuously from link into menu. The
   padding-top below provides visual breathing room without a real gap. */
.nav-dropdown { position: relative; display: flex; align-items: center; }
.nav-dropdown-menu {
  position: absolute; top: 100%; left: 0;
  padding: .6rem .5rem .5rem; min-width: 200px;
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .15s ease;
  z-index: 60;
}
.nav-dropdown:hover .nav-dropdown-menu,
.nav-dropdown:focus-within .nav-dropdown-menu {
  opacity: 1; visibility: visible; pointer-events: auto;
}
.nav-dropdown-menu-inner {
  background: var(--card); border: 1px solid var(--line);
  box-shadow: var(--shadow); padding: .5rem;
}
/* Categories fill down a column, then wrap into another rather than
   growing one long list forever — the count of rows before it wraps is
   fixed, so the column count grows with the category list instead. */
.nav-dropdown-cats {
  display: grid; grid-auto-flow: column; grid-template-rows: repeat(8, auto);
  gap: .1rem 1.2rem;
}
.nav-dropdown-menu a {
  display: block; text-transform: none; letter-spacing: 0; font-weight: 600;
  font-size: .88rem; color: var(--text-soft); padding: .5rem .7rem;
  border-bottom: 0; white-space: nowrap;
}
.nav-dropdown-menu a:hover { background: rgba(var(--accent-rgb), .12); color: var(--text); }
.nav-dropdown-all {
  color: var(--accent-soft) !important; font-weight: 700 !important;
  border-bottom: 1px solid var(--line) !important;
  margin-bottom: .3rem; padding-bottom: .6rem !important;
}
.nav-search {
  flex: 1; max-width: 300px; margin-left: auto;
  display: flex; align-items: center; gap: .5rem;
  padding: .55rem .8rem; border: 1px solid var(--line); background: var(--card);
}
.nav-search::before {
  content: "⌕"; color: var(--accent); font-size: 1rem; line-height: 1; flex: none;
}
.nav-search input {
  flex: 1; width: 100%; padding: 0; border: 0; background: transparent;
  color: var(--text); font: 400 .88rem/1 var(--font);
}
.nav-search input::placeholder { color: var(--muted); }
.nav-search input:focus { outline: none; }
.nav-search:focus-within { border-color: var(--accent); }
/* The business-mode toggle: ONE button both ways. Off is an outlined
   accent invitation that fills on hover (the ghost-inverts convention
   every other outlined control here follows); on, the same button is the
   solid accent chip with a drawn x, and hover shifts to --accent-hover --
   the same state response as .btn-primary, NOT an inversion (a solid
   hollowing out on hover read as broken, and was).
   The button must not change size when toggled: the hidden
   .biz-mode-sizer span (base.html) always carries the longer OFF label,
   stacked under .biz-mode-face on the same grid cell, so the longest
   label sets one width for both states and the visible face just
   re-centers. No magic width to keep in sync with the copy.
   Height ceiling: the logo beside this is 30px and nothing in
   .header-row may exceed it (see the .cart-link comment below).
   .78rem/1 text + .32rem padding + a 1.5px border computes to ~26px,
   clearing it -- don't fatten the padding or font without rechecking,
   and the face's 9px svg sits under the text's line box so it adds
   nothing. */
.biz-mode-toggle { display: flex; align-items: center; gap: .35rem; }
.biz-mode-btn {
  display: inline-grid; background: none; border: 1.5px solid var(--accent);
  cursor: pointer; font: 700 .78rem/1 var(--font); color: var(--accent-soft);
  padding: .32rem .7rem; white-space: nowrap;
}
.biz-mode-face, .biz-mode-sizer {
  grid-area: 1 / 1; display: inline-flex; align-items: center;
  justify-content: center; gap: .3rem; white-space: nowrap;
}
.biz-mode-sizer { visibility: hidden; }
.biz-mode-btn:hover { background: var(--accent); color: var(--on-accent); }
.biz-mode-btn-on { background: var(--accent); color: var(--on-accent); }
.biz-mode-btn-on:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--on-accent); }
.cart-link {
  display: flex; align-items: center; gap: .45rem; position: relative;
  text-decoration: none; font-weight: 700; font-size: .92rem;
}
.cart-link:hover { color: var(--accent-soft); }
/* Hit area, not layout. The visible link is 23px tall -- one pixel under
   WCAG 2.5.8's 24x24 -- and the logo beside it is 30px. Setting a
   min-height on either would make it the tallest thing in .header-row and
   push the whole header ~8px taller, moving a bar that --header-h and the
   full-viewport hero both measure against. A transparent inset overlay
   extends the tappable box past the visible one instead: pointer target
   clears 44px, rendered layout is byte-identical. Vertical only, so it
   cannot reach the neighbouring control across the 1.6rem flex gap. */
.cart-link::after,
.logo::after {
  content: ""; position: absolute; left: 0; right: 0;
  top: 50%; height: 44px; transform: translateY(-50%);
}
.logo { position: relative; }
.cart-icon { flex: none; }
.cart-count {
  font-family: var(--font-mono); color: var(--accent);
}
.cart-count::before { content: "\00b7\0020"; }
.cart-count.bump { animation: add-pop .4s ease; }

/* --- flashes ------------------------------------------------------------
   Resting state is fully visible (no animation needed) so server-rendered
   flashes just appear on load with no JS dependency. New toasts added by
   store.js (showToast) start from .flash-enter and transition to this
   resting state; dismissal transitions to .flash-out. Using one transition
   for both directions — rather than a keyframe animation plus a later
   inline-style transition, which is what used to fight each other and
   visibly "stick" mid-animation when toasts got dismissed in a hurry
   (rapid add-to-cart clicks) — keeps every state change on the same rails. */
.flashes { position: fixed; top: 84px; right: 1rem; z-index: 100; display: grid; gap: .5rem; }
.flash {
  background: var(--card-2); color: var(--text); padding: .7rem 1.1rem;
  border: 1px solid var(--line); border-top: 2px solid var(--good);
  box-shadow: var(--shadow); font-size: .9rem; max-width: 340px;
  opacity: 1; transform: translateY(0);
  transition: opacity .28s ease, transform .28s ease;
}
.flash-error { border-top-color: var(--bad); }
.flash.flash-enter { opacity: 0; transform: translateY(-16px); }
.flash.flash-out { opacity: 0; transform: translateY(-10px); }

/* --- buttons ------------------------------------------------------------ */
.btn {
  display: inline-block; background: var(--accent); color: var(--on-accent);
  border: 1.5px solid var(--accent); cursor: pointer;
  font: 700 .85rem/1 var(--font); letter-spacing: .02em; text-transform: uppercase;
  padding: .8rem 1.5rem; text-decoration: none; text-align: center;
  transition: background .15s, border-color .15s, transform .12s, box-shadow .15s;
}
.btn:hover {
  background: var(--accent-hover); border-color: var(--accent-hover);
  box-shadow: 0 6px 24px var(--glow); transform: translateY(-1px);
}
.btn:active { transform: scale(.98); }
.btn-lg { padding: 1rem 1.9rem; font-size: .9rem; }
/* .btn-sm is the add-to-cart button on every product card -- the most
   tapped control in the store -- and its padding alone computed to roughly
   28px tall. min-height brings it to the 44px thumb minimum without
   inflating the padding, so the label keeps its compact proportions and
   the button simply gains vertical hit area. */
.btn-sm {
  padding: .5rem 1rem; font-size: .76rem;
  min-height: 44px; display: inline-flex; align-items: center; justify-content: center;
}
/* Add-to-cart confirmation: swap to a green "Added" state and pop, instead
   of the page navigating/reloading (store.js submits this via fetch). */
.btn.is-added {
  background: var(--good) !important; border-color: var(--good) !important;
  color: var(--on-good) !important; animation: add-pop .4s ease;
}
@keyframes add-pop { 0% { transform: scale(1); } 45% { transform: scale(1.12); } 100% { transform: scale(1); } }
.btn-block { display: block; width: 100%; }
.btn-ghost { background: transparent; color: var(--text); border-color: var(--line-inner); }
.btn-ghost:hover { background: var(--text); color: var(--bg); border-color: var(--text); box-shadow: none; }
.btn-accent { background: var(--accent); border-color: var(--accent); }
/* The business-mode "Add to quote" button next to a solid "Add to cart":
   accent outline keeps it a secondary path without looking disabled. */
.btn-quote { background: transparent; color: var(--accent); border-color: var(--accent); }
.btn-quote:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--on-accent); }

/* --- hero ---------------------------------------------------------------- */
.hero {
  position: relative; overflow: hidden;
  background:
    radial-gradient(900px 460px at 50% 0%, rgba(var(--accent-rgb), .20), transparent 64%),
    radial-gradient(700px 400px at -8% 112%, rgba(var(--accent-rgb), .08), transparent 60%),
    var(--bg);
  border-bottom: 1px solid var(--line);
}
/* Full-viewport hero: everything above "Shop by category" fills the first
   screen — exactly, not "at least". height (not min-height) makes this a
   hard ceiling: min-height only sets a floor, so whenever the hero's
   intrinsic content ran taller than the viewport the box grew past the
   screen and the next section peeked in on a short scroll. Because this is
   now a ceiling, every piece of content sized below (intro, chips, panel,
   stat band) has to compress to fit inside it instead of pushing it taller
   — see the svh-based clamps throughout this file's hero rules, and the
   LEGIBILITY FLOOR note below.

   LEGIBILITY FLOOR: body/label text in this hero never compresses below
   12px (0.75rem), and leader-chip names never below 12.8px (0.8rem) — both
   comfortably legible. If a viewport is so short that fitting everything
   above that floor is impossible, the hero falls back to scrolling rather
   than shrinking further or clipping — see the `max-height: 420px` query
   at the end of the responsive block, which reverts this to the old
   min-height behavior. In practice only extreme landscape phones (~390px
   tall, e.g. 844×390) hit that fallback; every size in the normal matrix
   (down to 360×640 portrait) fits within the floor at exact height.

   100svh (not vh) so mobile browser chrome doesn't clip the stat band on
   load. */
.hero-full { height: calc(100svh - var(--header-h)); display: flex; flex-direction: column; }
/* justify-content: flex-start (rather than center) pins the intro near the
   top of the hero instead of vertically centering the intro+diagram as one
   group — a small svh-clamped top padding gives it room to breathe under
   the header. .exploded (below) hugs its own content height rather than
   growing to fill this container, and carries margin: auto on its own rule
   so any slack on a tall/large viewport splits evenly into the gap above
   and below the diagram instead of collecting as one dead zone under it. */
.hero-full .hero-inner {
  flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: flex-start;
  padding-top: clamp(.75rem, 3svh, 2.25rem);
}
/* Blueprint grid: a static CSS-only fallback, always fully visible so the
   hero never depends on JS to show it. store.js upgrades this on load by
   hiding it and sketching the same grid back in one line at a time via an
   SVG overlay, like it's being ruled out with a pencil. */
.hero::before {
  content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background:
    repeating-linear-gradient(0deg, rgba(255, 255, 255, .035) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(90deg, rgba(255, 255, 255, .035) 0 1px, transparent 1px 56px);
}
.hero-grid-js::before { display: none; }
.hero-grid-svg { position: absolute; top: 0; left: 0; z-index: 0; pointer-events: none; }
.hero-grid-line { stroke: rgba(255, 255, 255, .035); shape-rendering: crispEdges; }

/* Top/bottom padding scale gently with viewport height (clamped) so tall
   screens get more generous breathing room around the intro instead of a
   single fixed gap that reads the same at every size. */
.hero-inner {
  position: relative; z-index: 1;
  padding: clamp(1rem, 6svh, 5.5rem) 1.25rem clamp(.75rem, 3svh, 3rem);
}
.hero-intro {
  position: relative; text-align: center; max-width: 46rem;
  margin: 0 auto clamp(.9rem, 3svh, 3.5rem);
}
/* Headline is height-aware, not just width-aware: the preferred term is
   whichever is SMALLER of the sitewide 5vw scale or a 4.6svh scale, so a
   short-but-wide viewport (a landscape phone) shrinks with height instead
   of staying huge just because the screen is wide, while a tall-but-narrow
   phone still scales with width exactly as the sitewide h1 does. Floor
   1.3rem (20.8px) — above the 12px body-text floor — keeps the (manually
   2-line, see the <br> in the markup) headline readable when compressed. */
.hero-intro h1 { margin: 0 0 clamp(.4rem, 1.4svh, 1rem); font-size: clamp(1.3rem, min(5vw, 4.6svh), 3.4rem); }
.h1-accent { color: var(--accent); }
/* Not width-scaled sitewide, so needs its own svh clamp; floor 0.8rem
   (12.8px) sits just above the 12px legibility floor. */
.hero-sub { font-size: clamp(.8rem, 2.6svh, 1.05rem); line-height: 1.6; color: var(--text-soft); margin: 0 auto; max-width: 34rem; }

/* Exploded index: two chip columns flanking a bracket-framed CTA panel.
   width: 100% (not just max-width) is load-bearing: .exploded is a flex
   item (of .hero-inner, a column flex container) with auto side margins for
   centering, and a flex item with auto cross-axis margins never stretches
   to fill its line regardless of align-items — it falls back to shrink-to-
   fit sizing instead, which silently capped this at its own max-content
   width (~870px) well short of the max-width below on every viewport size.
   Declaring width: 100% gives it an explicit (non-auto) size again, so it
   fills up to max-width as intended and the auto margins center it there
   (the inline auto margins below have no free space to consume once width
   is pinned to 100%, so they're a no-op there — same as the old `0 auto` —
   and exist only so the shorthand can carry the block-axis auto too).
   max-width itself scales up on very large displays (clamped) so the
   diagram doesn't look marooned at a fixed 1060px on a 2560px+ screen.

   No flex-grow: earlier this had flex-grow so it would absorb leftover
   vertical space in .hero-inner up to a max-height ceiling — but on tall
   viewports that ceiling was the *only* thing standing between "diagram"
   and "diagram stretched to fill the screen," which pulled the 5-chip
   columns' justify-content: space-between gaps wide open and left the
   panel's bottom-anchored CTA stranded below a bare void (see the comments
   at .exploded-col and .exploded-panel > .btn). Dropping the grow (flex:
   0 1 auto) lets the diagram hug its own natural content height instead —
   already scaled sensibly by the svh clamps throughout this block — so the
   columns and panel stay dense at every viewport. margin: auto then lets
   whatever's actually left over in .hero-inner split evenly into the gap
   above the diagram (below the intro) and the gap below it (above the stat
   band), so surplus height on a big display reads as breathing room framing
   one dense object, not one dead zone dumped underneath it. The intro
   itself isn't part of this flex item, so it's unaffected and stays pinned
   near the top by .hero-full .hero-inner's justify-content: flex-start.
   max-height remains as a defensive ceiling only — content-hugging keeps
   the diagram well under it at every size in the supported matrix, so in
   practice it no longer binds; it stays in case future content ever grows
   past what the svh clamps expect. */
.exploded {
  /* Center column widens modestly with --hero-boost too (380px -> ~535px at
     2560x1440, ~590px at the boost cap) — needed so the panel's boosted
     padding/button don't eat so much of a still-380px-wide column that the
     CTA's own text is forced to wrap into a near-square block. Modest
     relative to the padding growth it's making room for, so the column
     still reads as the same proportions, just roomier. */
  position: relative; display: grid; grid-template-columns: 1fr calc(380px + var(--hero-boost) * .287) 1fr;
  gap: 0; align-items: stretch; width: 100%; margin: auto;
  /* max-width ceiling raised from a flat 1340px so the diagram can go
     meaningfully wider on a 2560px+ display — at those widths 64vw (1638-
     1690px) now clears the ceiling instead of being capped by it. Only
     matters ≥ ~2500px wide (64vw first exceeds 1340px there); 1920-and-
     under viewports were already below the old cap, so this is a no-op
     for them. */
  max-width: clamp(1060px, 64vw, 1600px);
  flex: 0 1 auto; min-height: 0;
  /* max-height ceiling grows with --hero-boost too — it's a defensive cap,
     not a driver of size (the grid still hugs its children's content), but
     the children below now render taller on large displays, so this cap
     has to grow in step or it would start clipping them right as the
     scale-up starts to matter. */
  max-height: calc(clamp(240px, 44svh, 360px) + var(--hero-boost) * 3);
}
.exploded-col {
  display: flex; flex-direction: column; justify-content: space-between;
  /* Chip-row gap rises only modestly with --hero-boost (a small .02
     coefficient vs. .05-.09 on the chip content below) so at every size the
     columns stay dense, grouped reading — the failure mode being guarded
     against is gaps growing as fast as the chips themselves and reopening
     the "scattered marks" look the flex-grow removal fixed. */
  gap: calc(clamp(.35rem, 1.4svh, .75rem) + var(--hero-boost) * 0.046);
}

.leader-chip { display: flex; align-items: center; gap: 0; text-decoration: none; color: inherit; }
.exploded-col-b .leader-chip { flex-direction: row-reverse; }
/* Resting state is a real border + background-color — the correct, final
   look, on its own, with zero dependency on JS or the box-draw animation
   below. .hero-anim-ready (added by store.js, only once it's confirmed
   animation is wanted) swaps this over to the animated gradient-strip
   version further down; if that class is never added — JS blocked, an
   error, prefers-reduced-motion — this is what's on screen, permanently
   correct, never a blank box. */
.leader-chip-item {
  display: flex; align-items: center; gap: .7rem; flex: 1;
  /* Ceilings grow with --hero-boost (see the shared var's comment up in
     :root) so a chip is genuinely bigger on a large display, not just
     centered in more space — 0 boost below 900px viewport height keeps
     this byte-identical to the pre-boost values at 1440x900 and below.
     Padding's coefficients are deliberately small relative to the icon/
     text coefficients below — a rebalance pass found the original padding
     growth (.054/.049) badly outpacing the icon+text content it surrounds,
     so a chip on a large display read as a big empty rectangle with a
     small label floating in the middle. Padding now reads as margin around
     a content block that carries most of the scale-up instead. */
  padding: calc(clamp(.4rem, 1.6svh, .75rem) + var(--hero-boost) * 0.011) calc(clamp(.55rem, 1.6svh, .9rem) + var(--hero-boost) * 0.0095);
  border: 1px solid var(--line);
  background: var(--card);
  transition: border-color .15s ease, background-color .15s ease;
}
.leader-chip:hover .leader-chip-item { border-color: var(--accent); background: var(--card-2); }
.leader-chip-icon {
  width: calc(clamp(18px, 3.2svh, 28px) + var(--hero-boost) * 0.085);
  height: calc(clamp(18px, 3.2svh, 28px) + var(--hero-boost) * 0.085);
  opacity: .85;
}
.leader-chip-text { display: grid; gap: .1rem; }
/* Floor 0.8rem (12.8px) — above the 12px legibility floor — is the chip
   name's compressed minimum; it's the one piece of hero copy a visitor
   actually reads as a label, so it gets a slightly higher floor than the
   bare 12px minimum. The ceiling (unlike the floor) grows with
   --hero-boost — scaling up is the whole point on a large display, only
   the compressed minimum is fixed. Coefficient raised substantially from
   an earlier pass (.00103, effectively flat) so the label text is actually
   part of what makes a large-display chip bigger, instead of the icon and
   padding doing all the work while the name that identifies the chip stays
   pinned near its floor. */
.leader-chip-name { font-weight: 700; font-size: calc(clamp(.8rem, 1.9svh, .92rem) + var(--hero-boost) * 0.009); line-height: 1.1; color: var(--text); }
.leader-chip-count { font: 400 calc(clamp(.68rem, 1.3svh, .68rem) + var(--hero-boost) * 0.0045) / 1 var(--font-mono); color: var(--muted); }
/* Resting state: fully drawn out, dot lit — no animation, no dependency on
   JS. .hero-anim-ready re-animates these in from a hidden start (below). */
.leader-line {
  width: 34px; height: 1px; flex: none;
  background: repeating-linear-gradient(90deg, var(--line-inner) 0 4px, transparent 4px 8px);
}
.exploded-col-a .leader-line { transform-origin: left; }
.exploded-col-b .leader-line { transform-origin: right; }
.leader-dot {
  width: 5px; height: 5px; flex: none; border-radius: 50%; background: var(--accent);
}

.exploded-panel {
  position: relative; display: flex; flex-direction: column;
  /* Vertical coefficient was .151793 — by far the largest in this file —
     because the chip columns beside it used to grow so tall (bloated
     padding, see .leader-chip-item) that only inflating the panel's own
     padding this hard kept it visually filled instead of leaving a bare
     graph-paper void under a comically tall empty panel. Now that the
     columns are dense (chip padding cut, chip content doing the growing
     instead), the panel doesn't need to manufacture height from its own
     padding — .exploded-panel > .btn's margin-top: auto already absorbs
     any real leftover space from matching the column's height. Cut hard,
     same principle as the chip padding above. */
  padding: calc(clamp(.75rem, 2.8svh, 1.6rem) + var(--hero-boost) * 0.01) calc(clamp(.85rem, 2.4svh, 1.5rem) + var(--hero-boost) * 0.006);
  border: 1px solid var(--line-inner);
}
/* The graph-paper texture + fill live on ::before, behind the box-draw
   border layer and the bracket-frame corner marks, so the border-drawing
   animation below only has to animate the border layer, not this whole
   stack. z-index: -1 is load-bearing, not decorative: without it, this
   absolutely-positioned layer paints *after* (i.e. on top of) the panel's
   own non-positioned in-flow content (title/tag/stats/button) per normal
   CSS stacking rules — it only looked fine before because animating those
   children's opacity (fade-in) happens to promote them into their own
   stacking context as a side effect, which is exactly the animation this
   panel must survive without. Negative z-index puts the texture in the
   one paint phase (behind in-flow content) that isn't contingent on any
   animation running. */
.exploded-panel::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background:
    repeating-linear-gradient(0deg, rgba(255, 255, 255, .04) 0 1px, transparent 1px 22px),
    repeating-linear-gradient(90deg, rgba(255, 255, 255, .04) 0 1px, transparent 1px 22px),
    linear-gradient(180deg, var(--panel-grad-top), var(--panel-grad-bottom));
}
/* Bracket-frame corner marks: four explicit spans (not ::before/::after —
   a frame needs all four corners and pseudo-elements only give you two),
   used on any panel that should read as "the thing to focus on here". */
.blueprint-frame { position: relative; }
.frame-mark { position: absolute; width: 12px; height: 12px; pointer-events: none; }
.frame-mark-tl { top: -1px; left: -1px; border-top: 1.5px solid var(--accent); border-left: 1.5px solid var(--accent); }
.frame-mark-tr { top: -1px; right: -1px; border-top: 1.5px solid var(--accent); border-right: 1.5px solid var(--accent); }
.frame-mark-bl { bottom: -1px; left: -1px; border-bottom: 1.5px solid var(--accent); border-left: 1.5px solid var(--accent); }
.frame-mark-br { bottom: -1px; right: -1px; border-bottom: 1.5px solid var(--accent); border-right: 1.5px solid var(--accent); }

/* --- box-draw: the hero's boxes trace their own border in on load, like a
   pen tracing an outline, clockwise from the top-left corner. Four
   linear-gradient layers stand in for the four sides; each keyframe step
   grows one side's background-size from 0 to full before starting the
   next, so the shape appears to be drawn rather than just faded in. The
   fill/texture is a separate layer (or ::before, for the panel) so this
   only ever animates background-size on the border layers.

   Everything in this block is scoped under .hero-anim-ready — a class
   store.js adds to .hero, and only .hero, once it's confirmed the load
   animation should run (skipped entirely under prefers-reduced-motion).
   Every element it targets already has a correct, fully-visible resting
   look declared elsewhere (real border + background-color on the chips/
   panel above, default opacity/transform everywhere else) with NO
   animation attached at the base level. Only once .hero-anim-ready lands
   do these rules swap the box over to the animated gradient-strip version
   and start the fade/draw/pop sequence from a hidden state — so if JS
   never runs, throws, or is blocked, or the animation is interrupted for
   any reason, what's on screen is the plain resting styles, never a blank
   or half-drawn box. This mirrors the .hero::before blueprint grid, which
   is the same "static and correct first, JS upgrades it" contract. */
.hero-anim-ready .box-draw {
  --bd: var(--line);
  --fill: var(--card);
  border: 0;
  background-image:
    linear-gradient(var(--bd), var(--bd)), linear-gradient(var(--bd), var(--bd)),
    linear-gradient(var(--bd), var(--bd)), linear-gradient(var(--bd), var(--bd)),
    linear-gradient(var(--fill), var(--fill));
  background-repeat: no-repeat;
  background-position: top left, top right, bottom right, bottom left, 0 0;
  animation: box-draw .275s linear both;
  animation-delay: calc(var(--delay-base) + var(--i, 0) * var(--stagger));
}
.hero-anim-ready .leader-chip:hover .leader-chip-item { --bd: var(--accent); --fill: var(--card-2); }
.hero-anim-ready .box-draw-panel {
  --bd: var(--line-inner);
  background-image:
    linear-gradient(var(--bd), var(--bd)), linear-gradient(var(--bd), var(--bd)),
    linear-gradient(var(--bd), var(--bd)), linear-gradient(var(--bd), var(--bd));
  background-position: top left, top right, bottom right, bottom left;
  animation-name: box-draw-panel;
}
.hero-anim-ready .box-draw > * { animation: fade-in .14s ease-out both; animation-delay: calc(var(--delay-base) + var(--i, 0) * var(--stagger) + .2s); }
.hero-anim-ready .box-draw-panel .frame-mark { animation: frame-pop .15s ease-out both; animation-delay: calc(var(--delay-base) + .29s); }
.hero-anim-ready .box-draw-panel > .exploded-panel-head,
.hero-anim-ready .box-draw-panel > .exploded-panel-stats,
.hero-anim-ready .box-draw-panel > .exploded-panel-latest,
.hero-anim-ready .box-draw-panel > .btn {
  animation: fade-in .15s ease-out both; animation-delay: calc(var(--delay-base) + .21s);
}
.hero-anim-ready .leader-line {
  animation: line-draw .175s linear both;
  animation-delay: calc(var(--delay-base) + var(--i, 0) * var(--stagger) + .315s);
}
.hero-anim-ready .leader-dot {
  animation: dot-pop .15s ease-out both;
  animation-delay: calc(var(--delay-base) + var(--i, 0) * var(--stagger) + .49s);
}
@keyframes box-draw {
  0%   { background-size: 0% 1px, 1px 0%, 0% 1px, 1px 0%, 100% 100%; }
  22%  { background-size: 100% 1px, 1px 0%, 0% 1px, 1px 0%, 100% 100%; }
  50%  { background-size: 100% 1px, 1px 100%, 0% 1px, 1px 0%, 100% 100%; }
  78%  { background-size: 100% 1px, 1px 100%, 100% 1px, 1px 0%, 100% 100%; }
  100% { background-size: 100% 1px, 1px 100%, 100% 1px, 1px 100%, 100% 100%; }
}
@keyframes box-draw-panel {
  0%   { background-size: 0% 1px, 1px 0%, 0% 1px, 1px 0%; }
  22%  { background-size: 100% 1px, 1px 0%, 0% 1px, 1px 0%; }
  50%  { background-size: 100% 1px, 1px 100%, 0% 1px, 1px 0%; }
  78%  { background-size: 100% 1px, 1px 100%, 100% 1px, 1px 0%; }
  100% { background-size: 100% 1px, 1px 100%, 100% 1px, 1px 100%; }
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes frame-pop { from { opacity: 0; transform: scale(1.7); } to { opacity: 1; transform: scale(1); } }
@keyframes line-draw { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes dot-pop { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }

.exploded-panel-head {
  display: flex; align-items: baseline; justify-content: space-between; gap: .6rem;
  padding-bottom: calc(clamp(.4rem, 1.4svh, .8rem) + var(--hero-boost) * 0.006); border-bottom: 1px solid var(--line-inner);
}
.exploded-panel-title { font: 850 calc(clamp(1rem, 2.6svh, 1.3rem) + var(--hero-boost) * 0.060717)/1.05 var(--font); letter-spacing: -.02em; text-transform: uppercase; }
.exploded-panel-tag { font: 500 .62rem/1 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--accent); white-space: nowrap; }
/* gap and block padding (row spacing, not the numbers themselves) are cut
   the same way as the chip and panel padding above — let type carry the
   scale, not the empty space between/around it. */
.exploded-panel-stats { display: grid; gap: calc(clamp(.35rem, 1.2svh, .65rem) + var(--hero-boost) * 0.006); padding: calc(clamp(.5rem, 1.8svh, 1rem) + var(--hero-boost) * 0.008) 0; font: 400 calc(clamp(.75rem, 1.6svh, .78rem) + var(--hero-boost) * 0.018216)/1 var(--font); color: var(--text-soft); }
/* .exploded-panel is a column flex; align-items: stretch (inherited from
   .exploded's flanking chip columns) makes this panel as tall as the 5-chip
   columns beside it, well past what the head + stats naturally fill.
   margin-top: auto on the CTA anchors it to the panel's bottom edge instead
   of leaving it stranded mid-height with bare graph-paper texture below —
   reads as a technical drawing's title block (id up top, action pinned to
   the bottom edge) rather than unfinished content. */
/* Capped, not just slow-growing: min() puts a hard ceiling on how much
   --hero-boost can add here (9px/side vertical, 6px/side horizontal,
   reached once boost >= 300, i.e. viewport height >= 1200 — well short of
   the 700px cap everything else keeps scaling to). A previous pass let
   this padding keep pace with the rest of the diagram (.075896/.025,
   uncapped) and the CTA ballooned into a ~350x100px solid-orange field —
   DESIGN.md's One Accent Rule permits small accent fills (buttons,
   badges) but explicitly forbids orange as a large background field. The
   label's font-size isn't boosted at all (inherited fixed .85rem from
   .btn), so capping the padding keeps the rendered button in the
   56-64px-tall range even at 2560x1440 and 2560x1600, instead of the fill
   dwarfing its own text. */
.exploded-panel > .btn { margin-top: auto; padding: calc(clamp(.5rem, 1.6svh, .8rem) + min(var(--hero-boost) * 0.03, 9px)) calc(clamp(1rem, 2.4svh, 1.5rem) + min(var(--hero-boost) * 0.012, 6px)); }
.stat-row { display: flex; align-items: baseline; gap: .5rem; }
.stat-row .stat-fill { flex: 1; height: 1px; background: repeating-linear-gradient(90deg, var(--line-inner) 0 2px, transparent 2px 6px); }
.stat-row .stat-val { color: var(--text); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* Latest-additions mini-index: fills the graph-paper void the stats block
   used to leave above the bottom-anchored CTA (see the .exploded-panel > .btn
   comment above) with a real, live-queried job for the panel — the most
   recently added live products, each linking straight to its product page.
   Kept deliberately compact (name + mono price, one line each, ellipsis on
   overflow) so it reads as a dense reference index rather than a second
   product grid duplicating "Featured components" further down the page.
   Product names stay in the sans face per the Data-Only Mono Rule — only
   the price is mono. */
.exploded-panel-latest {
  display: flex; flex-direction: column; gap: calc(clamp(.3rem, 1svh, .5rem) + var(--hero-boost) * 0.006);
  border-top: 1px solid var(--line);
  padding-top: calc(clamp(.4rem, 1.4svh, .7rem) + var(--hero-boost) * 0.006); margin-bottom: calc(clamp(.4rem, 1.4svh, .7rem) + var(--hero-boost) * 0.006);
}
.exploded-panel-latest-label {
  font: 700 .62rem/1 var(--font); letter-spacing: .08em; text-transform: uppercase; color: var(--text-soft);
}
.latest-list { display: flex; flex-direction: column; gap: calc(clamp(.25rem, .9svh, .45rem) + var(--hero-boost) * 0.005); list-style: none; margin: 0; padding: 0; }
/* Row spacing (gap, above) is cut hard like everything else in this
   rebalance; the row's own font-size coefficient is cut too (.018216 ->
   .009) — not because type shouldn't carry the scale here, but because at
   the old rate a real product name ("Lenovo LOQ 15 Gaming (RTX 4060)", the
   longest in the current catalog) grew wider than the panel could hold at
   2560x1440/2560x1600 and rendered with a clipped ellipsis. Moderating the
   growth rate fits every current name without wrapping in the common case;
   .latest-name (below) now wraps instead of clipping as a second line of
   defense for any future name this rate doesn't anticipate. */
.latest-list a {
  display: flex; align-items: baseline; justify-content: space-between; gap: .6rem;
  text-decoration: none; color: var(--text); font-size: calc(clamp(.78rem, 1.6svh, .78rem) + var(--hero-boost) * 0.009); line-height: 1.3;
}
.latest-list a:hover .latest-name { color: var(--accent); }
/* Wraps instead of ellipsis-clipping: a product name long enough to
   outgrow the panel at large --hero-boost sizes (verified against the
   current catalog's longest name, and stress-tested with a name well
   longer than that) now degrades to a second line rather than being cut
   off mid-word. min-width: 0 (kept) is what lets this flex child shrink
   below its text's intrinsic width in the first place, which wrapping
   depends on. */
.latest-name { flex: 1 1 auto; min-width: 0; overflow-wrap: break-word; }
.latest-price { flex: none; font-family: var(--font-mono); color: var(--text-soft); font-variant-numeric: tabular-nums; }

.hero-stat-band {
  position: relative; z-index: 1; border-top: 1px solid var(--line);
  padding: clamp(.9rem, 3svh, 2rem) 1.25rem;
  display: flex; align-items: center; gap: clamp(1rem, 3svh, 2rem); flex-wrap: wrap;
  justify-content: space-between; max-width: 1180px; margin: 0 auto;
}
.hero-stat-band > div { display: grid; gap: .2rem; }
.hero-stat-band strong { font: 850 clamp(1rem, 2.6svh, 1.5rem)/1 var(--font); color: var(--text); }
.hero-stat-band span { font: 400 clamp(.75rem, 1.6svh, .82rem)/1 var(--font); color: var(--muted); }

/* --- sections -------------------------------------------------------------- */
.section { padding: 3.5rem 0; }
.section-tinted { background: var(--tile-deep); border-block: 1px solid var(--line); }
.section-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 1rem; margin-bottom: 1.6rem; flex-wrap: wrap;
}
.section-head h2 { margin: 0; }
.link-more { font-weight: 650; font-size: .9rem; text-decoration: none; color: var(--text-soft); }
.link-more:hover { color: var(--accent-soft); }

/* --- category grid ----------------------------------------------------------- */
.cat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; }
.cat-card {
  display: grid; justify-items: start; gap: .15rem;
  border: 1px solid var(--line); padding: 1.2rem; text-decoration: none; background: var(--card);
  transition: border-color .15s, transform .15s;
}
.cat-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.cat-card img { width: 52px; height: 52px; margin-bottom: .6rem; opacity: .9; }
.cat-name { font-weight: 750; font-size: .95rem; }
.cat-count { font: 400 .74rem/1 var(--font-mono); color: var(--muted); }

/* --- product cards --------------------------------------------------------------- */
.product-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.1rem; }
.card {
  position: relative; background: var(--card); border: 1px solid var(--line);
  overflow: hidden; display: flex; flex-direction: column;
  transition: border-color .15s, transform .15s;
}
.card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card-media {
  position: relative; display: block; padding: 1.4rem;
  background:
    radial-gradient(150px 100px at 50% 48%, rgba(var(--accent-rgb), .10), transparent 72%),
    var(--tile);
  border-bottom: 1px solid var(--line);
}
.card-media img { width: 100%; height: 120px; object-fit: contain; }
.card-media-fallback {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: .55rem; min-height: 120px;
}
.card-media-fallback .card-media-icon {
  width: 34px; height: 34px; object-fit: contain; opacity: .55;
}
.card-media-brand {
  font-size: 1.05rem; font-weight: 850; letter-spacing: .02em; text-transform: uppercase;
  color: var(--text-soft); text-align: center; line-height: 1.2;
}
.card-body { display: grid; gap: .3rem; padding: .9rem 1rem 1rem; flex: 1; align-content: start; }
.card-brand {
  font: 750 .68rem/1 var(--font-mono); letter-spacing: .06em; text-transform: uppercase;
  color: var(--muted);
}
.card-title {
  font-weight: 700; font-size: .95rem; text-decoration: none; line-height: 1.3;
  min-height: 2.5em; color: var(--text);
}
.card-title:hover { color: var(--accent-soft); }
/* Stretches the title's link hit area over the whole card (a "stretched
   link"), so brand/price/stock text and blank padding are clickable too.
   .card is the positioned ancestor it expands to fill. */
.card-title::after { content: ""; position: absolute; inset: 0; z-index: 1; }
.card-price { display: flex; align-items: baseline; gap: .5rem; margin-top: .15rem; }
.price { font-weight: 850; font-size: 1.1rem; font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
.price-xl { font-size: 1.8rem; }
.card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: .5rem; gap: .5rem; }
/* Quote-only card foot has no price row above it (see product_card() in
   _macros.html) -- a bit more top margin than the ordinary .card-foot
   stands in for the missing .card-price row, so the card-title-to-button
   spacing reads about the same regardless of which variant a given card is. */
.card-foot-quote { margin-top: .8rem; }
/* Lift the Add to Cart form above the stretched title link so it still
   adds directly instead of navigating to the product page. */
.card-foot form { position: relative; z-index: 2; }
.stock { font: 700 .72rem/1 var(--font-mono); letter-spacing: .01em; }
.stock-in { color: var(--good); }
.stock-low { color: var(--warn); }
.stock-out { color: var(--bad); }

/* --- how / trust -------------------------------------------------------------------- */
.how-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.1rem; }
.how-card { border: 1px solid var(--line); padding: 1.6rem; background: var(--card); }
.how-num {
  font: 850 .78rem/1 var(--font-mono); color: var(--accent); letter-spacing: .1em;
  display: block; margin-bottom: .7rem;
}
.how-card p { color: var(--text-soft); font-size: .93rem; margin: 0; }
.trust-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1.1rem; }
.trust { border: 1px solid var(--line); padding: 1.6rem; background: var(--card); }
.trust-mark {
  display: grid; place-items: center; width: 24px; height: 24px; margin-bottom: .7rem;
  border: 1px solid var(--accent); color: var(--accent); font-size: .8rem; font-weight: 850;
}
.trust h3 { margin-bottom: .35rem; color: var(--accent-soft); font-size: 1.05rem; }
.trust p { margin: 0; font-size: .88rem; color: var(--text-soft); }

/* --- shop -------------------------------------------------------------------------- */
.page-head { background: var(--tile-deep); border-bottom: 1px solid var(--line); padding: 2.4rem 0; }
.page-head h1 { margin-bottom: .15em; }
.page-head p { margin: 0; color: var(--text-soft); }
.shop-layout { display: grid; grid-template-columns: 230px 1fr; gap: 2.2rem; padding-block: 2rem 3.5rem; }
/* These are h2 for the document outline -- they are the sidebar's real
   sections, and the page previously jumped h1 -> h4, which leaves a screen
   reader navigating by heading with no route into the filters. They keep
   the small uppercase label look they had as h4: the heading LEVEL is
   structure, the treatment is context. Same reason .totals and
   .empty-state below restate their own size. */
.shop-filters h2 {
  margin: 1.4rem 0 .6rem; color: var(--muted);
  font-size: .82rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
}
.shop-filters h2:first-child { margin-top: 0; }
.check { display: flex; align-items: center; gap: .5rem; font-size: .92rem; cursor: pointer; color: var(--text-soft); }
.check input { accent-color: var(--accent); }
.shop-filters select {
  width: 100%; padding: .5rem .6rem; border: 1px solid var(--line);
  font: inherit; font-size: .9rem; background: var(--card); color: var(--text);
}
.results-bar {
  display: flex; justify-content: space-between; align-items: baseline;
  font: 400 .82rem/1 var(--font-mono); color: var(--muted); margin-bottom: 1rem;
}
.results-bar a { color: var(--accent-soft); }
.shop-results .product-grid { grid-template-columns: repeat(3, 1fr); }
.pagination { display: flex; gap: .4rem; justify-content: center; margin-top: 2rem; }
.pagination a, .page-current {
  /* 44px, not 38: the WCAG 2.5.5 / platform minimum for a thumb. These sit
     directly adjacent to one another, which is exactly where an undersized
     target becomes a mis-tap onto the wrong page. */
  min-width: 44px; height: 44px; display: grid; place-items: center;
  border: 1px solid var(--line); text-decoration: none; font: 650 .85rem/1 var(--font-mono);
}
.pagination a:hover { border-color: var(--accent); color: var(--accent-soft); }
.page-current { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.empty-state { text-align: center; padding: 4rem 1rem; }
/* h2 for the outline (it is the only heading in an otherwise empty
   region), sized as the h3 it used to be -- a full-scale uppercase
   headline shouting "YOUR CART IS EMPTY" is the wrong tone for a dead end
   the visitor did not choose. */
.empty-state h2 { font-size: 1.15rem; font-weight: 750; text-transform: none; letter-spacing: 0; }
.empty-state a { color: var(--accent-soft); }
.empty-state .btn { color: var(--on-accent); }

/* --- product page ---------------------------------------------------------------------- */
.breadcrumbs { font: 400 .78rem/1 var(--font-mono); color: var(--muted); padding-block: 1.1rem; }
.breadcrumbs a { text-decoration: none; }
.breadcrumbs a:hover { color: var(--accent-soft); }
.product-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; padding-bottom: 2.5rem; }
.product-media {
  position: relative; border: 1px solid var(--line-inner);
  background:
    repeating-linear-gradient(0deg, rgba(255, 255, 255, .035) 0 1px, transparent 1px 22px),
    repeating-linear-gradient(90deg, rgba(255, 255, 255, .035) 0 1px, transparent 1px 22px),
    var(--tile);
  display: grid; place-items: center; padding: 3rem; min-height: 380px;
}
.product-media {
  /* The panel now holds art and a measure under it, so it stacks rather
     than centring one child. A cut-out photo sits on the tile itself, and
     the shadow is what stops it reading as pasted on. */
  grid-auto-flow: row; align-content: center; gap: 1rem;
}
/* An accent glow behind the product, sized and placed to sit under the art
   rather than filling the panel -- with the background keyed out, light
   reads as coming off the product instead of washing the whole tile. Its
   own layer rather than another background-image on .product-media, so the
   blueprint grid stays crisp on top of it. */
.product-media::before {
  content: ""; position: absolute; left: 50%; top: 50%;
  /* The floor glow: a soft wash on the tile, doing the work where the
     product does not cover it. Kept gentle -- the halo below is what the
     eye reads as light coming off the product. */
  width: 92%; height: 82%; transform: translate(-50%, -50%);
  background: radial-gradient(closest-side,
    rgba(var(--accent-rgb), .16), rgba(var(--accent-rgb), .06) 55%, transparent 78%);
  filter: blur(36px);
  pointer-events: none;
}
/* Every child sits above the glow. Direct-child selector on the image, not
   `.product-media img`: the thumbnail strip below puts images inside the
   panel too, and they are not the product shot -- they must not take its
   height cap or its drop-shadows. */
.product-media > img, .product-measure, .product-thumbs { position: relative; z-index: 1; }
.product-media > img {
  max-height: 300px; object-fit: contain;
  /* Two shadows, both tracing the image's own alpha -- which is the point
     of cutting the background out. The dark one seats the product on the
     tile; the accent one is the aura, and because drop-shadow follows the
     silhouette rather than the box, it reads as light off the product
     however much of the panel the product fills. A photo still on its
     white ground gets a rectangular glow, which is a fair warning that it
     has not been cut out. */
  filter:
    drop-shadow(0 16px 20px rgba(0, 0, 0, .5))
    drop-shadow(0 0 30px rgba(var(--accent-rgb), .40));
}
/* Dimension rule: end ticks, hairlines, one mono figure between them. */
.product-measure {
  display: flex; align-items: center; gap: .5rem; width: 100%;
  color: var(--muted); font: 400 .68rem/1 var(--font-mono); letter-spacing: .04em;
}
.product-measure-rule {
  flex-grow: 1; height: 1px; background: var(--line-inner); position: relative;
}
.product-measure-rule::before {
  content: ""; position: absolute; top: -4px; width: 1px; height: 9px;
  background: var(--line-inner);
}
.product-measure-rule:first-child::before { left: 0; }
.product-measure-rule:last-child::before { right: 0; }
.product-measure-text { white-space: nowrap; }
/* The photo strip, when a product has more than one photo. Drawn as what
   the rest of the page draws: small bordered plates, square corners, no
   shadow -- a thumbnail here is the media panel at 64px, not a new device.
   Only present in the markup when there are extras to show, so a
   single-photo product's panel is untouched. */
.product-thumbs {
  display: flex; flex-wrap: wrap; justify-content: center; gap: .5rem; width: 100%;
}
.product-thumb {
  width: 64px; height: 64px; padding: 6px; line-height: 0; cursor: pointer;
  border: 1px solid var(--line-inner); background: var(--tile-deep);
  transition: border-color .12s ease-out;
}
/* The one showing. Accent as a border and nothing else -- the same way an
   active filter chip and a selected admin thumbnail already say it, and
   the same reason orange is never a fill at this size. */
.product-thumb[aria-pressed="true"] { border-color: var(--accent); }
.product-thumb:hover { border-color: var(--accent-soft); }
.product-thumb img { width: 100%; height: 100%; object-fit: contain; }
.product-info h1 { font-size: clamp(1.5rem, 3vw, 2.1rem); text-transform: none; letter-spacing: 0; }
.sku { font: 400 .78rem/1 var(--font-mono); color: var(--muted); letter-spacing: .02em; }
.price-block { margin: 1.4rem 0 1rem; }
.price-main { display: flex; align-items: baseline; gap: .8rem; }
.price-main .price-xl { color: var(--accent-soft); }
/* Cart and business mode's quote button share one line (.buy-row holds
   the two separate forms -- see product.html's comment there); the row
   owns the vertical margins the buy form used to carry, so the buy form
   inside it goes margin-less and the standalone out-of-stock quote form
   keeps its own spacing below. Wraps rather than squeezes when the panel
   gets narrow -- the quote form drops under the cart row whole. */
.buy-row { display: flex; gap: .8rem; margin: 1.2rem 0 1.6rem; align-items: stretch; flex-wrap: wrap; }
.buy-row .buy-form, .buy-row .quote-cta-form { margin: 0; }
.buy-form { display: flex; gap: .8rem; margin: 1.2rem 0 1.6rem; align-items: stretch; }
/* Quote-only buy panel: no price, so this stands in for .price-main at the
   same position/margins -- text only, no numeral, nothing that reads as a
   figure a visitor could mistake for a price. */
.price-block-quote .quote-note { margin: 0; color: var(--text-soft); font-size: 1.05rem; }
.product-info > .btn { margin: 1.2rem 0 1.6rem; }
/* The quote_cta hint on an ordinary product's buy panel -- modest by
   design (task decision: it's a secondary path, not a competing CTA), so
   it's a plain link, not a button, sitting right under the buy form. */
.quote-cta-hint { margin: -.8rem 0 1.6rem; font-size: .88rem; }
.quote-cta-hint a { color: var(--accent-soft); }
.qty-wrap { display: flex; border: 1px solid var(--line); overflow: hidden; background: var(--card); }
.qty-btn { width: 42px; border: 0; background: var(--card); color: var(--text); font-size: 1.1rem; cursor: pointer; }
.qty-btn:hover { background: var(--card-2); color: var(--accent-soft); }
.qty-input {
  width: 54px; border: 0; text-align: center; font: 700 1rem var(--font-mono);
  background: var(--card); color: var(--text);
  -moz-appearance: textfield; appearance: textfield;
}
.qty-input::-webkit-outer-spin-button, .qty-input::-webkit-inner-spin-button { -webkit-appearance: none; }
/* The input is borderless by design -- .qty-wrap draws the single border
   around the −/input/+ trio -- so a focus ring on the input alone would sit
   inside that frame and read as a misaligned box. Focus is therefore shown
   on the wrapper, the same device .nav-search already uses, which also
   makes the whole control read as focused rather than one third of it.
   Replaces a bare `outline: none` that left keyboard users with no focus
   indicator at all on the two highest-stakes screens in the store. */
.qty-input:focus { outline: none; }
.qty-wrap:focus-within {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--glow);
}
.product-desc { color: var(--text-soft); max-width: 36rem; }
/* Distributor catalogue flags (products.eu_required / parcel_shippable).
   Deliberately NOT styled as an error: neither one is a problem with the
   product, they are things about ordering it that cost money or time if
   nobody says them until after checkout. Warn-toned, sitting above the
   description rather than below the buy button, so they are read before the
   decision and not after it. */
.order-flags { list-style: none; padding: 0; margin: 1.4rem 0 0; display: grid; gap: .5rem; }
.order-flag {
  border-left: 2px solid var(--warn); background: var(--card);
  padding: .7rem .9rem; font-size: .88rem; color: var(--text-soft);
}
.order-flag strong { color: var(--text); font-weight: 700; }
.assurances { list-style: none; padding: 0; margin: 1.4rem 0 0; display: grid; gap: .45rem; }
.assurances li { padding-left: 1.4rem; position: relative; font-size: .92rem; color: var(--text-soft); }
.assurances li::before { content: "✓"; position: absolute; left: 0; color: var(--good); font-weight: 800; }
.specs { padding-bottom: 1rem; }
.spec-table { width: 100%; border-collapse: collapse; max-width: 720px; }
.spec-table th, .spec-table td {
  text-align: left; padding: .65rem .9rem; font-size: .92rem;
  border-bottom: 1px dashed var(--line-inner);
}
.spec-table th { width: 38%; color: var(--muted); font-weight: 650; font-family: var(--font-mono); font-size: .82rem; }
.spec-table td { font-family: var(--font-mono); }
/* "All specifications" toggle (helpers.partition_specs's folded rows).
   Ghost/outlined like .btn-ghost and .biz-mode-btn -- transparent fill,
   inverts solid on hover -- rather than the accent: this discloses more
   of the same page, it isn't a call to action, and the One Accent Rule
   keeps orange off controls that aren't. Sans/700 for the label (this is
   a name for a control, not a data value); the "(N)" count is mono, same
   as .cat-count, since it's the one thing on the button worth comparing.
   aria-expanded is the single state of record -- store.js only ever
   flips that attribute plus the rows' `hidden` property, and both the
   chevron rotation and the count's visibility key directly off it here. */
.spec-toggle {
  display: inline-flex; align-items: center; gap: .5rem;
  margin-top: 1rem; padding: .6rem 1.1rem;
  background: transparent; border: 1.5px solid var(--line-inner);
  color: var(--text); font: 700 .8rem/1 var(--font);
  text-transform: uppercase; letter-spacing: .02em; cursor: pointer;
}
.spec-toggle:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.spec-toggle-count {
  font-family: var(--font-mono); font-weight: 400;
  text-transform: none; letter-spacing: 0; color: var(--muted);
}
.spec-toggle:hover .spec-toggle-count { color: inherit; }
/* "Fewer specifications" replaces the whole label (see store.js) rather
   than keeping a stale count next to it, so the count hides with it. */
.spec-toggle[aria-expanded="true"] .spec-toggle-count { display: none; }
.spec-toggle-chevron { flex: none; transition: transform .15s ease; }
.spec-toggle[aria-expanded="true"] .spec-toggle-chevron { transform: rotate(180deg); }

/* --- cart / checkout --------------------------------------------------------------------- */
.cart-layout, .checkout-layout {
  display: grid; grid-template-columns: 1.6fr 1fr; gap: 2.5rem; padding-block: 2rem 3.5rem;
  align-items: start;
}
.cart-line {
  display: grid; grid-template-columns: 72px 1fr 90px 90px; gap: 1rem; align-items: center;
  padding: 1rem 0; border-bottom: 1px solid var(--line);
}
.cart-line img { width: 72px; height: 56px; object-fit: contain; background: var(--tile); padding: .4rem; }
.cart-line-info { display: grid; gap: .15rem; }
.cart-line-info a { font-weight: 700; text-decoration: none; }
.cart-line-info a:hover { color: var(--accent-soft); }
/* The cart's quantity field is a standalone bordered input, not the
   product page's −/input/+ trio inside .qty-wrap, so the wrapper's
   focus-within rule never reaches it. It gets the field treatment used by
   every other bordered input in the system instead. */
.cart-line .qty-input { border: 1px solid var(--line); padding: .4rem; width: 70px; }
.cart-line .qty-input:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--glow);
}
.cart-line-total { font-weight: 800; text-align: right; font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
.cart-actions { display: flex; justify-content: space-between; align-items: center; padding-top: 1.2rem; }
.link-danger {
  background: none; border: 0; color: var(--bad); font: 600 .8rem var(--font);
  cursor: pointer; text-decoration: underline; padding: 0;
}
.cart-line-info .sku { display: flex; align-items: baseline; gap: .35rem; flex-wrap: wrap; }

/* Cart sections. Headings only render when the cart actually holds both
   kinds of line (see cart.html), so these never appear on a cart that is
   all one thing -- the spacing above is why they carry their own top
   margin rather than relying on the line above them. */
.cart-section-head {
  font-size: .78rem; font-weight: 700; text-transform: uppercase;
  letter-spacing: .12em; color: var(--muted); margin: 2rem 0 0;
}
.cart-lines > .cart-section-head:first-of-type { margin-top: 0; }
.cart-section-note { font-size: .84rem; color: var(--text-soft); margin: .4rem 0 .2rem; max-width: 48ch; }
/* A quote line keeps the same four-column grid as a buy line -- the price
   column holds an em dash rather than collapsing, so the two sections stay
   in vertical alignment with each other down the page. */
.cart-line-unpriced { color: var(--muted); font-weight: 500; }
.totals-quote { color: var(--text-soft); }
.totals-head {
  font: 700 .72rem var(--font-mono); text-transform: uppercase; letter-spacing: .1em;
  color: var(--muted); margin: 1rem 0 .2rem;
}

/* The mixed-cart choice at checkout. Two stacked radio cards rather than a
   bare radio pair: each option changes what happens to money, so each gets
   room for a sentence saying exactly what. */
.checkout-choice { display: grid; gap: .6rem; margin-bottom: 1.6rem; }
.checkout-choice .choice {
  display: grid; grid-template-columns: auto 1fr; gap: .7rem; align-items: start;
  padding: .9rem 1rem; border: 1px solid var(--line); background: var(--card);
  cursor: pointer;
}
.checkout-choice .choice-on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--glow); }
.checkout-choice input { margin-top: .2rem; }
.checkout-choice .choice-body { display: grid; gap: .2rem; }
.checkout-choice .choice-title { font-weight: 700; font-size: .95rem; }
.checkout-choice .choice-note { font-size: .84rem; color: var(--text-soft); }

/* Card footer for a quote-only product: no price row and no stock badge, so
   the button would otherwise sit tight against the title. */
.card-quote-note { margin-top: .8rem; font-size: .84rem; color: var(--text-soft); }
/* Same accent button as .buy-form above it -- .65rem of top margin keeps
   the two from touching, matching the gap .buy-form itself opens above
   the price block. */
.quote-cta-form { margin: .65rem 0 1.6rem; }
.visually-hidden {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0; padding: 0; margin: -1px;
}

/* Bracket-frame totals panel — same "the panel that matters most" role as
   the hero's exploded-panel, reused sitewide anywhere a summary/CTA needs
   to read as the thing to focus on. */
/* h2 for the outline, h3's size in practice: this panel is narrow, and the
   full uppercase h2 scale would wrap "Order summary" onto two lines and
   outweigh the total underneath it -- which is the number that matters. */
.totals h2 { font-size: 1.15rem; font-weight: 750; text-transform: none; letter-spacing: 0; }
.totals {
  position: relative;
  background:
    repeating-linear-gradient(0deg, rgba(255, 255, 255, .035) 0 1px, transparent 1px 22px),
    repeating-linear-gradient(90deg, rgba(255, 255, 255, .035) 0 1px, transparent 1px 22px),
    linear-gradient(180deg, var(--panel-grad-top), var(--panel-grad-bottom));
  border: 1px solid var(--line-inner);
  padding: 1.5rem; position: sticky; top: 96px;
}
.totals h3 { margin-bottom: 1rem; }
.totals-row {
  display: flex; justify-content: space-between; gap: 1rem;
  font: 400 .88rem/1 var(--font-mono); padding: .4rem 0; color: var(--text-soft);
}
.totals-item { color: var(--text-soft); font-size: .82rem; }
.totals-grand {
  border-top: 1px solid var(--line-inner); margin-top: .6rem; padding-top: .8rem;
  font-weight: 850; font-size: 1.1rem; color: var(--text);
}
.totals .btn { margin-top: 1.1rem; }
.totals-hint { font: 400 .76rem/1 var(--font-mono); color: var(--accent-soft); margin: .1rem 0; }
/* Wraps to several lines, unlike the one-line hints beside it, so it needs a
   real line-height -- and it is a caveat about the number above rather than
   an encouragement to spend more, so it borrows the warn tone. */
.totals-hint-freight { line-height: 1.5; color: var(--warn); margin: .5rem 0 .2rem; }
/* Same wrapping rule as the freight hint above (a full sentence, not a
   one-line aside) but neutral tone -- this isn't a caveat about the number
   above it, it's just naming how a four-figure order gets paid before
   Stripe's own page does. */
.totals-hint-ach { line-height: 1.5; color: var(--muted); margin: .5rem 0 .2rem; }

/* h2 for the outline -- these are the form's real sections, and the page
   went h1 -> h3, so a screen reader jumping by heading skipped a level on
   the highest-stakes form in the store. Sized as the h3 they were: four
   full-scale uppercase headlines down a checkout column would compete with
   the fields they label. */
.checkout-form h2 {
  margin: 1.6rem 0 .8rem;
  font-size: 1.15rem; font-weight: 750; text-transform: none; letter-spacing: 0;
}
.checkout-form h2:first-of-type { margin-top: 0; }
.field { display: grid; gap: .3rem; margin-bottom: .9rem; }
.field label { font-size: .84rem; font-weight: 700; color: var(--text-soft); }
.field input, .field textarea {
  padding: .65rem .8rem; border: 1px solid var(--line);
  font: inherit; font-size: .95rem; background: var(--card); color: var(--text); width: 100%;
}
.field input:focus, .field textarea:focus {
  outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow);
}
.field-error input { border-color: var(--bad); }
.field-msg { color: var(--bad); font-size: .8rem; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
/* Only the offline fallback uses this now: with Stripe configured there is
   no method to state, because the customer picks one on Stripe's page and
   checkout.html names the destination instead (issue #9). The value rides
   along in a hidden input either way. */
.pay-method {
  border: 1px solid var(--line); background: var(--card);
  padding: .85rem 1rem; margin: 0 0 .6rem; font-weight: 650;
}
.pay-note { font-size: .82rem; color: var(--muted); }
.checkout-form .btn-block { margin-top: 1.4rem; }
/* The hand-off button IS the payment step: with Stripe on, checkout.html
   has no Payment section left, so this button is the whole of what the
   customer is told about payment (issue #9). Hence Stripe's own brand
   purple and glyph rather than the store accent -- it should read as the
   Stripe button it is, and it is the last thing seen before Stripe's page,
   so matching it is continuity rather than decoration.

   #635bff is fixed, not themed: every other accent on the site swaps with
   the theme, but a brand colour that drifts is no longer the brand colour.
   The hover glow is overridden for the same reason -- .btn:hover's is
   built from --glow, which is the store accent and clashes on purple. */
.btn-stripe, .btn-stripe:hover {
  color: #fff; border-color: #635bff;
}
.btn-stripe { background: #635bff; }
.btn-stripe:hover {
  background: #544bef; box-shadow: 0 6px 24px rgba(99, 91, 255, .34);
}
/* inline-flex, not block, so the glyph and label sit on one baseline;
   btn-block's width: 100% still applies. */
.btn-stripe.btn-block { display: inline-flex; align-items: center; justify-content: center; gap: .6rem; }
.stripe-glyph { width: 1.15em; height: 1.15em; flex: none; fill: currentColor; }
/* Rendered once and shown or hidden, so the toggle back to "Continue to
   Stripe" does not have to rebuild it. */
#checkout-submit:not(.btn-stripe) .stripe-glyph { display: none; }

/* --- quote request (reuses .checkout-form/.field/.totals) --------------------------------- */
/* Off-screen, not display:none -- a field a screen reader still can't see
   (aria-hidden + tabindex="-1" on the input itself keeps it out of both the
   tab order and the accessibility tree) but that a form-filling bot, which
   works off the raw DOM rather than computed visibility, still finds. */
.hp-field { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.quote-form-error { margin: 0 0 1.2rem; }
.quote-lines { display: grid; gap: .7rem; margin-bottom: .7rem; }
.quote-line { display: grid; grid-template-columns: 1fr 90px; gap: .8rem; }
.quote-line-product, .quote-line-qty {
  padding: .65rem .8rem; border: 1px solid var(--line);
  font: inherit; font-size: .95rem; background: var(--card); color: var(--text); width: 100%;
}
.quote-line-product:focus, .quote-line-qty:focus {
  outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow);
}
.quote-add-line { margin-bottom: 1.8rem; }

/* --- confirmation ------------------------------------------------------------------------- */
.confirm-wrap { display: grid; place-items: center; padding: 3.5rem 1.25rem; }
.confirm-card {
  position: relative; max-width: 560px; width: 100%; text-align: center;
  background:
    repeating-linear-gradient(0deg, rgba(255, 255, 255, .035) 0 1px, transparent 1px 22px),
    repeating-linear-gradient(90deg, rgba(255, 255, 255, .035) 0 1px, transparent 1px 22px),
    linear-gradient(180deg, var(--panel-grad-top), var(--panel-grad-bottom));
  border: 1px solid var(--line-inner); padding: 2.5rem 2rem;
}
.confirm-check {
  width: 64px; height: 64px; margin: 0 auto 1.2rem;
  background: var(--good); color: var(--on-good-strong); display: grid; place-items: center;
}
.confirm-number { font: 850 1.05rem/1 var(--font-mono); letter-spacing: .04em; color: var(--accent-soft); }
.confirm-items { width: 100%; border-collapse: collapse; margin: 1.6rem 0; text-align: left; }
.confirm-items td { padding: .5rem 0; border-bottom: 1px dashed var(--line-inner); font: 400 .88rem/1 var(--font-mono); }
.confirm-items td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.confirm-items .sub td { color: var(--text-soft); }
.confirm-items .grand td { font-weight: 850; font-size: 1.05rem; border-bottom: 0; color: var(--text); }

/* --- footer ---------------------------------------------------------------------------------- */
.site-footer { background: var(--bg-deep); color: var(--text-soft); margin-top: 2rem; border-top: 1px solid var(--line); }
/* minmax(0, …) on every track, not a bare fr. A bare `1fr` is
   `minmax(auto, 1fr)`, and `auto` floors the track at its content's
   min-content width -- so one unbreakable string is enough to push a
   column past its share and the whole grid past the viewport. The support
   column holds a mailto address with no break opportunity in it, which is
   exactly that string: measured at a 485px viewport the two mobile tracks
   computed to 219.95px + 275.52px = 495.5px inside a 485px box, giving
   every storefront page 70px of horizontal scroll. The wrap rule below is
   the other half -- the track may now shrink, so the address has to be
   allowed to break rather than simply overflow its own column. */
.footer-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr);
  gap: 2.5rem; padding: 3.2rem 1.25rem 2rem;
}
/* Scoped to the mailto, not every footer link: `anywhere` also feeds
   min-content sizing, which is what lets the track actually shrink -- but
   applied broadly it would let ordinary category names break mid-word for
   no reason. The address is the only string here with no natural break
   opportunity. */
.footer-grid a[href^="mailto:"] { overflow-wrap: anywhere; }
.footer-logo { height: 36px; width: auto; filter: invert(1); margin-bottom: 1rem; }
/* h2 for the outline -- these are the footer's real column headings, and
   as h4 they left every page jumping a level on the way down. Keeps the
   small uppercase label treatment h4 gave them: same reasoning as the shop
   sidebar and the checkout sections above. */
.site-footer h2 {
  color: var(--text); margin-bottom: .8rem;
  font-size: .82rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
}
.site-footer p { font-size: .88rem; }
.footer-grid a { display: block; color: var(--text-soft); text-decoration: none; font-size: .9rem; padding: .18rem 0; }
.footer-grid a:hover { color: var(--accent-soft); }
/* flex-wrap, and this is the whole fix for the footer's horizontal
   overflow. A nowrap flex row's min-content width is the SUM of its items'
   contributions, so the field and its button could never both fit in a
   half-width mobile column no matter how far the input was allowed to
   shrink -- `min-width: 0` and `flex: 1` were already here and did not
   help, because the sum, not the input, was the floor. Allowing a wrap
   makes the container's min-content the widest SINGLE item instead:
   measured 276px -> 183px against a ~222px mobile track. The button drops
   under the field on a narrow screen and the page stops scrolling
   sideways. */
.subscribe { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .8rem; }
.subscribe input {
  flex: 1; padding: .6rem .8rem; border: 1px solid var(--line);
  background: var(--card); color: var(--text); font: inherit; font-size: .88rem; min-width: 0;
}
.subscribe input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow); }
.subscribe .btn-accent { color: var(--on-accent); }
/* The field and its button computed to 43px -- one pixel under the 44px
   thumb minimum. Unlike the header there is nothing to disturb down here,
   so these just get the pixel rather than an overlay. */
.subscribe input, .subscribe .btn-accent { min-height: 44px; }
.footer-legal {
  display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;
  border-top: 1px solid var(--line); padding-block: 1.1rem;
  font: 400 .74rem/1 var(--font-mono); letter-spacing: .02em; color: var(--muted);
}
.footer-legal a { color: var(--muted); }
.footer-legal a:hover { color: var(--accent-soft); }

/* --- motion ---------------------------------------------------------------------------------------- */
@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }

/* Hero entrance: children stagger in on load. Landing splash only — these
   classes/elements only exist in the hero markup on index.html. The
   headline hovers into place first (rise), then each chip and the center
   panel trace their own border in (box-draw, above), then each leader-line
   draws out from its chip to the center and its dot lights up on arrival
   (line-draw/dot-pop, above) — one continuous "the diagram assembles
   itself" sequence rather than a single block fade. --i (set inline per
   element as the row index) and --stagger space the chip rows apart;
   every delay in the sequence is offset from the same --delay-base so the
   whole thing re-times as one unit if the base ever moves. */
.hero-anim { --delay-base: .225s; --stagger: .045s; }
.hero-intro > * { animation: rise .6s ease both; }
.hero-intro > *:nth-child(1) { animation-delay: .05s; }
.hero-intro > *:nth-child(2) { animation-delay: .18s; }
/* Belt-and-suspenders: store.js already checks prefers-reduced-motion and
   skips adding .hero-anim-ready entirely when it matches, so in the common
   case none of the .hero-anim-ready-scoped rules above are even live. This
   catches the rest — the OS setting flipping mid-session after the class
   was already added, or any environment that evaluates this media query
   independently of JS — and needs !important since the .hero-anim-ready
   rules above are more specific than a bare .box-draw selector would be. */
@media (prefers-reduced-motion: reduce) {
  .hero-intro > *,
  .hero-anim-ready .box-draw, .hero-anim-ready .box-draw-panel, .hero-anim-ready .box-draw > *,
  .hero-anim-ready .box-draw-panel .frame-mark,
  .hero-anim-ready .box-draw-panel > .exploded-panel-head,
  .hero-anim-ready .box-draw-panel > .exploded-panel-stats,
  .hero-anim-ready .box-draw-panel > .exploded-panel-latest,
  .hero-anim-ready .box-draw-panel > .btn,
  .hero-anim-ready .leader-line, .hero-anim-ready .leader-dot {
    animation: none !important;
  }
}

/* --- responsive --------------------------------------------------------------------------------- */
@media (max-width: 960px) {
  /* Stacked layout: content height is no longer a fixed 5-chip-row shape
     the desktop max-height above was tuned for — it wraps into rows of
     its own natural height instead. max-height stays reset to none (the
     desktop cap has no meaning once chips wrap), but .hero-full itself
     keeps its exact height from above — the chips go 2-up (not 220px/one-
     per-row) specifically so this stacked content is short enough to still
     fit inside it; see the flex-basis change below. margin resets to 0:
     the desktop rule's margin: auto exists to split leftover height above/
     below the diagram on tall desktop viewports, which has no bearing on
     the mobile exact-fit budget (there's no slack to split here) — reset
     it explicitly rather than rely on there never being any. */
  .exploded { grid-template-columns: 1fr; gap: clamp(.75rem, 2.4svh, 1.5rem); flex: none; max-height: none; margin: 0; }
  .exploded-col { flex-direction: row; flex-wrap: wrap; }
  .exploded-col-b .leader-chip { flex-direction: row; }
  .leader-line, .leader-dot { display: none; }
  .leader-chip-item { width: 100%; }
  /* 2-up, not one-per-row: at a 220px basis, 10 categories stacked one per
     row (plus the panel, intro and stat band) cannot fit one screen on a
     phone. A ~45% basis packs 2 per row (5 rows instead of 10); the last
     of an odd column's chips sits alone on its row and flex-grow: 1 (from
     the base .leader-chip-item rule) stretches it to fill the row, which
     reads as intentional rather than a gap. */
  .exploded-col > .leader-chip { flex: 1 1 45%; }
  /* Below 960px the panel is no longer align-items:stretch-matched to a
     5-chip column (that column is now a row-wrapped block, not a tall flex
     sibling) — so there's no void to fill here in the first place, and the
     phone-width hero is already at (or within ~15px of) the exact-fit
     budget's legibility floor with just the original two stat rows. Drop
     the wide-viewport-only fill (extra stat row + latest-additions list)
     rather than risk that razor-thin mobile budget. */
  .stat-row-extra, .exploded-panel-latest { display: none; }
  /* 2×2 grid instead of a 4-across wrapping flex row: a predictable two
     rows of two, rather than however many items the natural wrap point
     happens to fit at a given width — keeps stat-band height consistent
     and compact across every phone width in the matrix. */
  .hero-stat-band { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(.4rem, 1.4svh, .8rem) clamp(.7rem, 3vw, 1.4rem); }
  /* Second, steeper compression pass for phones: the desktop svh clamps
     above were tuned around a single 5-chip column. Stacked 2-up, the same
     content becomes up to 6 chip rows (two 3-row blocks flanking the
     panel) plus intro plus a 2-row stat band — more total rows than the
     desktop layout ever has, on viewports whose height (a portrait phone
     is 640–896px tall) reads as "generous" to a plain svh coefficient. A
     phone at 844px tall barely compressed under the desktop clamps at all
     (measured: chips and the stat band overlapped), and a first mobile-
     specific pass that fixed 390×844/414×896 still overflowed by ~60px at
     360×640 because every non-text property (padding, gaps, icon size —
     nothing here is body copy) was floored well above where it needed to
     be. This pass drops those non-text floors much further; the only
     floors held fixed at the stated legibility limits are the ones that
     are actually text a visitor reads: chip names at .8rem/12.8px, and
     everything else (sub copy, panel stats, stat-band labels) at .75rem/
     12px. Verified against real screenshots at 390×844, 414×896 and
     360×640 — no overlap, nothing clipped, at least ~15px of slack left
     over even at 360×640, the shortest non-fallback case. */
  .hero-inner { padding: clamp(.4rem, 1.4svh, 1rem) 1.25rem clamp(.35rem, 1svh, .8rem); }
  .hero-intro { margin-bottom: clamp(.4rem, 1svh, 1rem); }
  .hero-intro h1 { margin: 0 0 clamp(.2rem, .6svh, .5rem); font-size: clamp(1rem, 4.4vw, 1.5rem); }
  .hero-sub { font-size: clamp(.75rem, 1.8svh, .82rem); }
  .exploded { gap: clamp(.3rem, 1svh, .6rem); }
  .exploded-col { gap: clamp(.2rem, .6svh, .4rem); }
  .leader-chip-item { padding: clamp(.2rem, .6svh, .35rem) clamp(.35rem, .8svh, .5rem); }
  .leader-chip-icon { width: clamp(14px, 1.8svh, 18px); height: clamp(14px, 1.8svh, 18px); }
  .leader-chip-name { font-size: clamp(.8rem, 1.5svh, .85rem); }
  .exploded-panel { padding: clamp(.35rem, 1svh, .6rem) clamp(.5rem, 1svh, .8rem); }
  .exploded-panel-head { padding-bottom: clamp(.2rem, .5svh, .35rem); }
  .exploded-panel-title { font-size: clamp(.85rem, 1.6svh, .95rem); }
  .exploded-panel-stats { gap: clamp(.15rem, .4svh, .3rem); padding: clamp(.25rem, .6svh, .45rem) 0; font-size: clamp(.75rem, 1.2svh, .78rem); }
  .exploded-panel > .btn { padding: clamp(.2rem, .5svh, .35rem) clamp(.7rem, 1.2svh, 1rem); }
  .hero-stat-band { padding: clamp(.35rem, 1svh, .6rem) 1rem; }
  .hero-stat-band strong { font-size: clamp(.8rem, 1.6svh, 1rem); }
  .hero-stat-band span { font-size: clamp(.75rem, 1.2svh, .78rem); }
  .cat-grid, .product-grid, .shop-results .product-grid { grid-template-columns: repeat(2, 1fr); }
  .how-grid { grid-template-columns: 1fr; }
  .shop-layout { grid-template-columns: 1fr; }
  .shop-filters { border: 1px solid var(--line); padding: 1.1rem; background: var(--card); }
  .product-layout, .cart-layout, .checkout-layout { grid-template-columns: 1fr; }
  /* Desktop pins this panel to the top of a side column. Stacked on a
     phone it sits after the line items, so top-sticky is meaningless --
     but going fully static took the running total and the Checkout button
     off screen for the visitor least able to scroll back for them.
     Bottom-sticky keeps the panel in normal flow and pins its lower edge
     to the viewport while the items above are still being scrolled, which
     is where the total and the CTA live. max-height + auto overflow means
     a long summary can never grow to cover the page it belongs to. */
  .totals {
    position: sticky; top: auto; bottom: 0;
    max-height: 70svh; overflow-y: auto;
  }
  .footer-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  .nav-search { display: none; }
}
@media (max-width: 560px) {
  .cat-grid, .product-grid, .shop-results .product-grid { grid-template-columns: 1fr; }
  .header-row { flex-wrap: wrap; gap: .8rem; }
  /* The outlined button's text can't shrink (white-space: nowrap), so at
     360px let the whole toggle drop to its own wrapped line via the
     header-row rule above rather than let flexbox squeeze it illegibly. */
  .biz-mode-toggle { flex: none; }
  .main-nav { order: 3; width: 100%; justify-content: space-between; }
  .cart-line { grid-template-columns: 56px 1fr; grid-auto-rows: auto; }
  .cart-line .qty-input, .cart-line-total { grid-column: 2; justify-self: start; }
  .field-row { grid-template-columns: 1fr; }
  .quote-line { grid-template-columns: 1fr; }
  .buy-form { flex-direction: column; align-items: stretch; }
  /* Phone widths stack the row again: cart form, then quote form, each
     full width -- side by side has no room to breathe here. */
  .buy-row { flex-direction: column; align-items: stretch; }
  .buy-row .quote-cta-form { display: flex; }
  /* Match the stretched Add-to-cart above: a content-width orange button
     under a full-width one reads as an accident at phone widths. */
  .quote-cta-form .btn { display: block; width: 100%; flex: 1; }
  .qty-wrap { justify-content: center; }
  .hero-stat-band { gap: clamp(.6rem, 2svh, 1.2rem) clamp(1rem, 5vw, 2rem); }
}
/* Short-portrait-phone tightening (e.g. 360×640): the mobile pass above was
   verified against 390×844/414×896/360×640 and claimed ~15px of slack even
   at 360×640, but re-measurement found the stacked content (intro + up to
   6 chip rows + panel + stat band) actually running ~16px *past* the
   bottom of .hero-inner's flex-shrunk box at that height — invisible to a
   glance at .hero-inner's own rect (flexbox lets a shrunk box's overflowing
   children paint past its edge instead of clipping), but a real collision:
   the last chip row visually overlapped the "Authorized/Full warranty"
   stat-band text below it. Every text floor in the block above is already
   at its stated legibility limit at this height (chip names .8rem/12.8px,
   everything else .75rem/12px) — there's no text left to shrink — so this
   trims non-text spacing (padding/gaps/line-height) a little further, and
   only below 700px tall so 390×844/414×896 (which already fit with room to
   spare) render byte-for-byte as before. */
@media (max-width: 960px) and (max-height: 700px) {
  .hero-sub { line-height: 1.28; }
  .hero-inner { padding: clamp(.2rem, 1svh, 1rem) 1.25rem clamp(.2rem, .75svh, .8rem); }
  .hero-intro { margin-bottom: clamp(.2rem, .75svh, 1rem); }
  .hero-intro h1 { margin-bottom: clamp(.08rem, .45svh, .5rem); }
  .exploded { gap: clamp(.16rem, .75svh, .6rem); }
  .exploded-col { gap: clamp(.1rem, .45svh, .4rem); }
  .leader-chip-item { padding: clamp(.1rem, .45svh, .35rem) clamp(.3rem, .6svh, .5rem); }
  .exploded-panel { padding: clamp(.2rem, .75svh, .6rem) clamp(.35rem, .75svh, .8rem); }
  .exploded-panel-head { padding-bottom: clamp(.1rem, .4svh, .35rem); }
  .exploded-panel-stats { gap: clamp(.08rem, .3svh, .3rem); padding: clamp(.12rem, .45svh, .45rem) 0; }
  .exploded-panel > .btn { padding: clamp(.1rem, .4svh, .35rem) clamp(.6rem, 1.2svh, 1rem); }
  .hero-stat-band { padding: clamp(.2rem, .75svh, .6rem) 1rem; }
}
/* Degenerate-case fallback: an extreme landscape phone (~390px tall, e.g.
   844×390) is short enough that fitting the headline + diagram + stat band
   above the 12px legibility floor is not possible — every clamp above is
   already at its floor by that point. Rather than compress text past
   legible or clip content with overflow, this one case reverts to the old
   min-height behavior: the hero grows to its natural content height and
   the page scrolls, exactly like any other section that doesn't fit one
   screen. 420px is comfortably above the ~390px real-world case (common
   landscape-phone heights) and comfortably below every portrait phone
   height in the test matrix (640px+), so it only ever fires for this one
   class of viewport. */
@media (max-height: 420px) {
  .hero-full { height: auto; min-height: calc(100svh - var(--header-h)); }
}

/* Shop sidebar price range: two number inputs reading as one control, which
   is why they share a row and a single "to" between them rather than each
   carrying its own visible label. minmax(0, 1fr) so a long value shrinks the
   input instead of widening the sidebar (the same bare-fr trap the footer
   grid hit). */
.price-range {
  display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center; gap: .5rem; margin-bottom: .8rem;
}
.price-range input {
  width: 100%; padding: .5rem .6rem; min-height: 44px;
  border: 1px solid var(--line); background: var(--card); color: var(--text);
  font: 400 .85rem var(--font-mono);
}
.price-range input:focus {
  outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--glow);
}
.price-range span { font-size: .78rem; color: var(--muted); }
.shop-filters select { width: 100%; }
