/* ==========================================================================
   Yiertek — the stylesheet
   ==========================================================================

   EVERY COLOUR, TYPE SIZE AND SPACE VALUE IN THIS FILE COMES FROM
   src/styles/tokens.css. There are no literals below this comment, and the
   `tokenDiscipline` probe in scripts/probe-home.mjs fails the build gate if one
   appears: a literal here is a value nobody can find again, and the last time
   this file held them the home page grew a second design system to disagree
   with them (src/styles/home.css, deleted 2026-08-30).

   The reasoning for the values themselves — why Newsreader and not Instrument
   Serif, why ΔL 0.060, why the space scale is the body line box — is in
   docs/design-foundations.md. Read that before changing a number here.
   ========================================================================== */

/* ==========================================================================
   reset
   ========================================================================== */

*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

body {
  margin: 0;
  background: var(--bg-0);
  color: var(--fg-2);
  font-family: var(--type-body-family);
  font-size: var(--type-body-size);
  font-weight: var(--type-body-weight);
  letter-spacing: var(--type-body-track);
  line-height: var(--type-body-leading);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

img, picture, svg, video { display: block; max-width: 100%; }
img { height: auto; }

/* An unclassed heading takes the h3 step — the smallest structural voice —
   rather than a size of its own. Every heading that matters carries one of the
   ramp classes below; this rule only guarantees that one which does not still
   arrives in the right family and the right colour. */
h1, h2, h3, h4 {
  color: var(--fg-1);
  font-family: var(--type-h3-family);
  font-weight: var(--type-h3-weight);
  line-height: var(--type-h3-leading);
  letter-spacing: var(--type-h3-track);
  margin: 0;
  text-wrap: balance;
}

p { margin: 0; text-wrap: pretty; }
ul, ol { margin: 0; padding: 0; list-style: none; }

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

/* -- interaction states, in one place ------------------------------------- */
/* Focus must always be visible — the whole palette is low-contrast, so the ring
   is the accent rather than the browser default. It is declared once, here, and
   the light band re-points --accent rather than writing a second rule. */
:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
  border-radius: var(--radius-1);
}

/* Chinese: the ramp is switched by :root:lang(zh) in tokens.css — family and
   weight together, not just size — so there is nothing to override per heading
   any more. Only the paragraph leading, which is a script property rather than
   a ramp step, is set here. */
:lang(zh) body, :lang(zh) p { line-height: var(--type-body-leading); }

/* The phone header keeps the 72px bar it always had — it is the one piece of
   geometry on this site that was already right, and the burger panel, its
   max-height and the brackets were all measured against it. */
@media (max-width: 1024px) {
  :root { --header-h: 72px; --header-h-scrolled: 72px; }
}

/* ==========================================================================
   layout
   ========================================================================== */

.wrap { width: 100%; max-width: var(--max); margin-inline: auto; padding-inline: var(--gutter); }
.wrap--text { max-width: var(--max-text); }
.wrap--mid { max-width: 1280px; }

/* Bands alternate --bg-0 and --bg-1 with NO rule between them. The two planes
   are 0.060 apart in OKLab lightness — a step you can see, where the pair this
   replaced (#0a0a0a / #141414) was 0.047 and read as one long black. */
/* One rule owns vertical band rhythm. A band that wants more or less air asks
   for --section-y-tight, not for a padding of its own — the moment two bands
   carry different hand-written numbers the page has no rhythm to break. */
.band { padding-block: var(--section-y); }
.band--surface { background: var(--surface); }
.band--tight { padding-block: var(--section-y-tight); }

/* --------------------------------------------------------------------------
   .band--light — the light plane
   --------------------------------------------------------------------------

   It is not a colour swap, it is a TOKEN swap. Every component on this site
   paints itself out of the alias names, so re-pointing them inside the band is
   enough for .eyebrow, .h2, .lead, .btn, .card, .spec, .chip, .tag, .features
   and .field to arrive already correct.

   The light ramp is the dark ramp's mirror, measured the same way: ΔL 0.060 in
   OKLab between planes, and every text/ground pair cleared against BOTH light
   planes rather than only against the band, so a card on the band needs no
   exception of its own. Numbers, and the rule they license, are in
   docs/design-foundations.md §2. */
.band--light {
  background: var(--bg-light-0);
  color: var(--fg-light-2);

  /* On a dark ground a plane RISES out of the black; on a light one it RECEDES
     into the paper. One step either way is the same 0.060 of lightness — that
     is the whole reason the ramps were built in OKLab.

     The PRIMARY names are re-pointed, not only the transition aliases: a
     component written against --fg-2 has to arrive correct inside this band
     just as one written against --muted does, or the band is a trap for the
     next person who writes a rule. */
  --bg-0: var(--bg-light-0);
  --bg-1: var(--bg-light-1);
  --bg-2: var(--bg-light-1);
  /* --bg-3 is --bg-light-1 and NOT --bg-light-2, since 2026-08-31.
     --bg-3 has exactly one consumer on this site, the chip, and the light ramp
     only ever guaranteed its pairs on light-0 and light-1 — light-2 was outside
     the promise, and nothing measured it. The E2 step took light-2 from
     L 0.8382 to 0.8101 and the chip's ink went with it: --fg-light-1 on
     --bg-light-2 is Lc 69.3, under the 75 a label is held to. On --bg-light-1
     the same ink is Lc 82.5 (12.96:1), and a status still recedes one step from
     the plate it sits on, which is what a chip on paper should do. */
  --bg-3: var(--bg-light-1);
  --fg-1: var(--fg-light-1);
  --fg-2: var(--fg-light-2);
  --fg-3: var(--fg-light-3);
  --fg-over-image: var(--fg-light-2);
  --fg-over-image-2: var(--fg-light-3);
  --ground: var(--bg-light-0);
  --surface: var(--bg-light-1);
  --white: var(--fg-light-1);
  --muted: var(--fg-light-2);
  --faint: var(--fg-light-3);
  --copper: var(--accent-light);
  --bronze: var(--accent-light-hover);
  --accent: var(--accent-light);
  --accent-hover: var(--accent-light-hover);
  --accent-rgb: var(--accent-light-rgb);
  --accent-on: var(--bg-light-0);
  /* The copper FILL follows --accent into the light ramp, or a control written
     against --accent-grad would paint the dark pair on an off-white band. */
  --accent-grad-1: var(--accent-light-grad-1);
  --accent-grad-2: var(--accent-light-grad-2);
  --accent-grad-hover-1: var(--accent-light-grad-hover-1);
  --accent-grad-hover-2: var(--accent-light-grad-hover-2);
  --accent-fill: var(--accent-light-fill);
  --accent-fill-hover: var(--accent-light-fill-hover);
  --danger: var(--danger-light);
  --line-soft: var(--line-light-soft);
  --line: var(--line-light);
  --line-strong: var(--line-light-strong);
  --hover-wash: rgb(var(--accent-light-rgb) / 0.10);
  --hover-wash-strong: rgb(var(--accent-light-rgb) / 0.16);
  --scrim-rgb: var(--fg-light-rgb);
}

/* The primary action on the light band is an INK pill, not a copper one: the
   copper's light cut is a text colour (4.5:1) and a 4.5:1 fill would carry a
   label at nothing. Ink gives 16.3:1 for the label and a 13.6:1 edge. */
.band--light .btn--primary { background: var(--fg-light-1); color: var(--bg-light-0); }
.band--light .btn--primary:hover { background: var(--fg-light-2); }

/* A dark-backdrop product render on a light card is the exact failure the
   manifest exists to prevent, only mirrored: a near-black plate as a slab on
   an off-white tile. So on the light band a dark-backdrop card keeps the
   ground palette — the photograph meets its own background either way, which
   is the whole rule. */
.band--light .card[data-backdrop='dark'] {
  --bg-0: var(--bg-0-dark);
  --bg-1: var(--bg-1-dark);
  --bg-2: var(--bg-2-dark);
  --bg-3: var(--bg-3-dark);
  --fg-1: var(--fg-1-dark);
  --fg-2: var(--fg-2-dark);
  --fg-3: var(--fg-3-dark);
  --ground: var(--bg-0);
  --surface: var(--bg-1);
  --white: var(--fg-1);
  --muted: var(--fg-2);
  --faint: var(--fg-3);
  --accent: var(--accent-dark);
  --accent-hover: var(--accent-hover-dark);
  --copper: var(--accent);
  --bronze: var(--accent-hover);
  /* …and the fill pair comes back with it. */
  --accent-grad-1: var(--accent-grad-1-dark);
  --accent-grad-2: var(--accent-grad-2-dark);
  --accent-grad-hover-1: var(--accent-grad-hover-1-dark);
  --accent-grad-hover-2: var(--accent-grad-hover-2-dark);
  --accent-fill: var(--accent-dark);
  --accent-fill-hover: var(--accent-fill-hover-dark);
  --accent-rgb: var(--accent-rgb-base);
  --line-soft: rgb(var(--accent-rgb) / 0.08);
  --line: rgb(var(--accent-rgb) / 0.14);
  --line-strong: rgb(var(--accent-rgb) / 0.26);
  --hover-wash: rgb(var(--accent-rgb) / 0.10);
  background: var(--surface);
  border-color: var(--line-light);
  color: var(--muted);
}

/* Label text that only the screen reader needs — the search field is named by
   a label it never shows, because a magnifier and a placeholder already say it
   to everyone else. */
.vh {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.skip {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 200;
  /* The skip link is a copper FILL, so it takes the fill treatment the primary
     button takes — it is the same object, and the one that appears first. */
  background-color: var(--accent-fill);
  background-image: var(--accent-grad);
  color: var(--accent-on);
  padding: 0.75rem 1.25rem;
  font-weight: 600;
}
.skip:focus { left: 0; }

/* ==========================================================================
   type utilities — the seven-step ramp, applied
   ==========================================================================

   One class per step. Each reads FIVE tokens — family, weight, size, tracking,
   leading — so a step is changed in tokens.css and nowhere else, and so a
   heading can never be "the same as the one above but bigger": the family and
   the weight move too.

     .h-hero  display     Newsreader 400   the full-bleed hero headline
     .h1      h1          Newsreader 400   a headline in a column
     .h2      h2          Archivo 600      the section statement
     .h3      h3          Archivo 600      the sub-head
     .lead    lead        Inter 300        the sentence under a heading
     (body)   body        Inter 400        set on <body>
     .small   caption     Inter 400        meta, captions, table cells

   Measures are capped by role, not by taste: a headline that runs 1600px is a
   paragraph in a large size. --measure-* are `ch` on latin and `em` on Chinese,
   because `ch` is the width of a zero and means nothing for a full-width glyph.
   ========================================================================== */

/* The one label style that is not a ramp step. Archivo, not Inter: it names the
   section a reader is entering, which is structure. Solid accent rather than
   the 75% alpha it carried — an alpha over a photograph composites to whatever
   the photograph is, and the eyebrow sits on one in every hero. */
.eyebrow {
  font-family: var(--font-structure);
  font-size: var(--type-eyebrow-size);
  font-weight: var(--type-eyebrow-weight);
  letter-spacing: var(--type-eyebrow-track);
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 var(--space-4);
}

.h-hero {
  font-family: var(--type-display-family);
  font-size: var(--type-display-size);
  font-weight: var(--type-display-weight);
  letter-spacing: var(--type-display-track);
  line-height: var(--type-display-leading);
  max-width: var(--measure-h1);
  /* Without this the hero splits "high-end" across lines at the hyphen on
     narrow phones, which reads as a typo rather than a wrap. */
  hyphens: none;
  overflow-wrap: break-word;
}
/* See keepCompounds() in pages.mjs — suppresses the break at a hard hyphen. */
.nbh { white-space: nowrap; }

.h1 {
  font-family: var(--type-h1-family);
  font-size: var(--type-h1-size);
  font-weight: var(--type-h1-weight);
  letter-spacing: var(--type-h1-track);
  line-height: var(--type-h1-leading);
  max-width: var(--measure-h1);
  /* Same as .h-hero: a hard hyphen in a compound ("high-end", "Built-in") is a
     break opportunity the browser will take on a phone, and a headline split
     there reads as a typo rather than as a wrap. */
  hyphens: none;
  overflow-wrap: break-word;
}

.h2 {
  font-family: var(--type-h2-family);
  font-size: var(--type-h2-size);
  font-weight: var(--type-h2-weight);
  letter-spacing: var(--type-h2-track);
  line-height: var(--type-h2-leading);
  max-width: var(--measure-h2);
}

.h3 {
  font-family: var(--type-h3-family);
  font-size: var(--type-h3-size);
  font-weight: var(--type-h3-weight);
  letter-spacing: var(--type-h3-track);
  line-height: var(--type-h3-leading);
}

/* The card title. Not an eighth step — it is the h3 step's family and weight at
   a fixed 17px, which is the size a card body can carry without competing with
   the section heading above the grid. */
.h4 {
  font-family: var(--type-h3-family);
  font-size: var(--fs-h4);
  font-weight: var(--type-h3-weight);
  letter-spacing: 0;
  color: var(--fg-1);
  line-height: 1.35;
}

.lead {
  font-family: var(--type-lead-family);
  font-size: var(--type-lead-size);
  font-weight: var(--type-lead-weight);
  letter-spacing: var(--type-lead-track);
  line-height: var(--type-lead-leading);
  color: var(--fg-2);
  max-width: var(--measure-lead);
}

.small {
  font-size: var(--type-caption-size);
  letter-spacing: var(--type-caption-track);
  line-height: var(--type-caption-leading);
}
.faint { color: var(--fg-3); }

/* Emphasis in running prose is WEIGHT and BRIGHTNESS, never the accent. Copper
   means "you can act on this"; a copper word in the middle of a sentence
   promises a link that is not there, and once emphasis is copper the eye stops
   reading the actual links as links. */
.em { color: var(--fg-1); font-weight: 500; }

/* Figures are a display role in the structural family: tabular, so a column of
   them lines up, and lining figures, so they sit on the cap line rather than
   hanging like old-style ones. */
.tnum { font-variant-numeric: lining-nums tabular-nums; }

.stack > * + * { margin-top: var(--space-4); }
.stack-sm > * + * { margin-top: var(--space-2); }
.stack-lg > * + * { margin-top: var(--block-gap); }

/* ==========================================================================
   icons — public/assets/icons.svg, through icon() in src/lib/html.mjs
   ==========================================================================
   Size comes from the class, never from width/height attributes: the same mark
   beside 14px type and inside a 44px control is one mark at two sizes, and that
   is a CSS decision. currentColor throughout, so an icon is whatever colour its
   control is — including while that control is hovered or focused. */
.icon {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  color: currentColor;
}
.icon--sm { width: 16px; height: 16px; }

/* ==========================================================================
   buttons — derived from the reference's pill-chip, not a UI kit
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.9rem var(--space-4);
  border-radius: var(--radius-1);
  /* A button is a control, not prose: Archivo, the structural voice. */
  font-family: var(--font-structure);
  font-size: var(--type-caption-size);
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
  border: 1px solid transparent;
  transition: background-color var(--transition), color var(--transition),
              border-color var(--transition);
}

/* The primary fill is a GRADIENT, not a plane. A solid over a 200px pill
   photographs as flat paint; two stops on the copper's own hue (H 60 since the
   2026-08-31 hue correction — the old H 74.4 was the beige, and two lit beiges
   are still beige), ±0.040 in OKLab L about it, give the pill a lit face and a
   shaded one while the MEAN of the fill stays exactly --accent, so the identity
   does not move. The label clears AA at both ends — 10.00:1 on the light stop,
   7.51:1 on the dark one — which is why the two stops are tokens with their
   contrast written beside them rather than a gradient improvised here.

   Hover deepens the same pair by 0.045 in L instead of lightening it: on a
   fill, "lit" reads as washed out, and the shape of the gradient is unchanged
   so it never snaps to a flat colour. background-image does not interpolate,
   so the swap is instant — the alternative was a plane that fades, which is
   the thing this rule exists to remove. */
.btn--primary {
  background-color: var(--accent-fill);
  background-image: var(--accent-grad);
  color: var(--accent-on);
}
.btn--primary:hover {
  background-color: var(--accent-fill-hover);
  background-image: var(--accent-grad-hover);
}

.btn--ghost { background: transparent; color: var(--copper); border-color: var(--line-strong); }
.btn--ghost:hover { background: var(--hover-wash); border-color: var(--accent); }

/* The enquiry button is disabled while its request is in flight. Dimmed rather
   than greyed: the copper stays recognisable, so it reads as the same button
   waiting, not as a different, broken one. */
.btn:disabled { opacity: 0.45; cursor: default; }
.btn--primary:disabled:hover {
  background-color: var(--accent-fill);
  background-image: var(--accent-grad);
}

.btn-row { display: flex; flex-wrap: wrap; gap: var(--space-3); }

.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.3rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  /* A chip is a STATUS ("new", a finish), not an action. It used to be copper
     on copper, which said "click me" about a word nobody can click — the
     rationing rule in docs/design-foundations.md §2. It is a plane and a
     hairline now, and it reads as a label. */
  background: var(--bg-3);
  color: var(--fg-1);
  font-family: var(--font-structure);
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

/* ==========================================================================
   hero
   ========================================================================== */

.hero {
  position: relative;
  min-height: min(88svh, 780px);
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  overflow: hidden;
  background: var(--ground);
}
.hero__media { position: absolute; inset: 0; }
/* The render is a near-black studio plate — the turntable's own values sit in
   the 6–18% band — so the scrim passes that bought legibility for the copy were
   also burying the product. Lifting the plate before the scrim costs no bytes
   and no request. Contrast is left almost alone: pushing it would crush the
   shadow the object stands in. */
.hero__media img { width: 100%; height: 100%; object-fit: cover; }
.hero--home .hero__media img {
  object-position: 58% 50%;
  filter: brightness(1.55) contrast(0.96) saturate(1.08);
}
/* Shaped around the product, not across the whole frame. The old vertical veil
   ran to 0.88 at the bottom edge — exactly where the renders place the object —
   so the turntable sat behind ~63% black. The copy well is now an ellipse over
   the text itself; the bottom veil is only as deep as the proof rail needs; the
   top pass keeps the fixed header legible. */
.hero__scrim {
  position: absolute;
  inset: 0;
  /* Interior heroes keep the original two-pass veil: they are bright lifestyle
     renders at 58svh, a different problem from this one. */
  background:
    linear-gradient(to right, rgb(var(--scrim-rgb) / 0.92) 0%, rgb(var(--scrim-rgb) / 0.55) 45%, rgb(var(--scrim-rgb) / 0) 78%),
    linear-gradient(to top, rgb(var(--scrim-rgb) / 0.88) 0%, rgb(var(--scrim-rgb) / 0.3) 50%, rgb(var(--scrim-rgb) / 0.1) 100%);
}
.hero--home .hero__scrim {
  background:
    radial-gradient(132% 145% at -6% 62%,
      rgb(var(--scrim-rgb) / 0.93) 0%, rgb(var(--scrim-rgb) / 0.86) 26%,
      rgb(var(--scrim-rgb) / 0.52) 50%, rgb(var(--scrim-rgb) / 0.2) 68%, rgb(var(--scrim-rgb) / 0) 86%),
    linear-gradient(to top, rgb(var(--scrim-rgb) / 0.74) 0%, rgb(var(--scrim-rgb) / 0.3) 13%, rgb(var(--scrim-rgb) / 0) 30%),
    linear-gradient(to bottom, rgb(var(--scrim-rgb) / 0.5) 0%, rgb(var(--scrim-rgb) / 0) 20%);
}
@media (max-width: 768px) {
  /* Narrow viewports crop toward the centre, so fall back to a flat veil. */
  .hero__scrim {
    background: linear-gradient(to top, rgb(var(--scrim-rgb) / 0.94) 15%, rgb(var(--scrim-rgb) / 0.55) 60%, rgb(var(--scrim-rgb) / 0.25) 100%);
  }
}
@media (max-width: 900px) {
  /* A phone crops to ~26% of a 16:9 plate and cut the product out of frame
     entirely — hence the black rectangle. Pan onto the object, and veil evenly
     rather than radially: there is no empty half left to grade away, so the
     copy sits ON the picture. Lifted harder because that veil takes the picture
     back down with it. */
  .hero--home .hero__media img { object-position: 70% 50%; filter: brightness(1.95) contrast(0.94) saturate(1.08); }
  .hero--home .hero__scrim {
    background:
      linear-gradient(to top,
        rgb(var(--scrim-rgb) / 0.93) 0%, rgb(var(--scrim-rgb) / 0.82) 24%,
        rgb(var(--scrim-rgb) / 0.68) 52%, rgb(var(--scrim-rgb) / 0.7) 78%, rgb(var(--scrim-rgb) / 0.88) 100%);
  }
}
.hero__body { position: relative; padding-block: clamp(3rem, 8vw, 7rem) clamp(2rem, 4vw, 3.5rem); }
/* The lower bound clears the 72px fixed header. Home only: this is the one hero
   whose content is taller than its min-height on a phone, so the block is laid
   out from the top and a 3rem pad put the eyebrow behind the lockup. */
.hero--home .hero__body { padding-block-start: clamp(5.5rem, 8vw, 7rem); }
/* 5.5rem, not 5: measured at 390px with the full lockup, the eyebrow sat at 48
   and the logo ended at 60. 88px clears the 72px bar with 16px to spare. */
/* A column, not a full-bleed row: capping it leaves the right of the frame to
   the product. The cap sits just above the widest the headline gets (~1030px at
   the 96px ceiling) — below that, "high-end" breaks at its own hyphen. */
.hero__copy { max-width: min(100%, 72rem); }

/* The proof rail rides in the hero's own bottom shelf. Hairline above it rather
   than a band colour change — the photograph runs underneath, and a solid ground
   would cut it in half. The veil is deep because 12px grey has to hold AA over
   the lit plinth the rail crosses; at that depth the blur it used to carry was
   invisible, and it cost a compositor layer in the LCP element's own stack. */
.hero__proof {
  position: relative;
  padding-block: clamp(1.25rem, 2.4vw, 2rem) clamp(1.5rem, 2.8vw, 2.25rem);
  border-top: 1px solid var(--line);
  background: linear-gradient(to top, rgb(var(--scrim-rgb) / 0.94), rgb(var(--scrim-rgb) / 0.8));
}
/* Two facts on one line: whose name is on the box, and where the box goes. The
   line used to be a single caption with two thirds of it empty. */
.hero__proof-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem 2rem;
  margin-bottom: clamp(1rem, 2vw, 1.5rem);
}
.hero__proof-label {
  font-size: var(--fs-eyebrow);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--fg-1);
}
.hero__markets {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.4rem 0.85rem;
  font-size: var(--fs-small);
  /* Over imagery, the same rule the hero lead follows: the body greys are
     tuned for the flat ground and drop below AA on a lit plate. */
  color: var(--fg-over-image-2);
}
.hero__markets-label {
  font-size: var(--fs-eyebrow);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
}
.hero__markets > span:last-child { display: flex; flex-wrap: wrap; gap: 0.4rem 0.85rem; }
/* The separator trails the region rather than leading the next one: as a
   `::before` it became the first thing on a wrapped line ("· Russia" alone),
   which reads as a bullet list that lost its first item. Never after the
   last one. */
.hero__markets > span:last-child > span { white-space: nowrap; }
.hero__markets > span:last-child > span:not(:last-child)::after {
  content: '·';
  margin-left: 0.85rem;
  color: var(--fg-3);
}
@media (max-width: 700px) {
  /* At phone width the head line stacks, and a right-aligned second half would
     read as a stray fragment. Keep both flush left, and tighten the four
     regions so they hold one line at 390 — wrapped, the last region sits alone
     under a trailing separator and costs the proof strip its place in the
     fold. Every saving here is spent keeping the ten marks above it. */
  .hero__proof-head { justify-content: flex-start; margin-bottom: 0.9rem; }
  .hero__markets { font-size: 0.8125rem; gap: 0.3rem 0.5rem; }
  .hero__markets > span:last-child { gap: 0.3rem 0.5rem; }
  .hero__markets > span:last-child > span:not(:last-child)::after { margin-left: 0.5rem; }
  .hero--home .hero__lead { margin-top: 1.25rem; }
  .hero--home .btn-row { margin-top: 2rem; }
  .hero__proof { padding-block: 1.1rem 1rem; }
}

/* Over imagery the muted body grey drops below AA; heroes get a brighter tone.
   Same for the eyebrow: copper held at 75% is a deliberate recession against
   the flat ground, and measures 4.1:1 over the lit plate. Full strength here
   only. */
.hero .lead { color: var(--fg-over-image); }
.hero__lead { max-width: 46ch; margin-top: 1.75rem; }
.hero .btn-row { margin-top: 2.75rem; }
/* A ghost button is copper-on-nothing, which is fine on the flat ground and
   not fine on a lit photograph: measured at 2.9:1 over the platter at phone
   width. It gets a plate of its own here — the same near-black, not a new
   colour — so the pair reads as two weights of one button rather than as one
   button and one outline that sometimes disappears. */
.hero .btn--ghost { background: rgb(var(--scrim-rgb) / 0.72); }
.hero .btn--ghost:hover { background: var(--hover-wash-strong); }

/* Corner brackets — two 64px L-marks, top-left and bottom-right ONLY. The
   asymmetry is the point: it reads as a viewfinder, not a frame. */
/* The top inset clears the fixed header — at a flat 2.5rem the top-left mark
   ran straight through the logo lockup. It is derived from --header-h plus a
   24px shoulder rather than written as a number, so a header that changes
   height cannot leave a bracket sitting on the wordmark again (that is exactly
   what the utility strip did, and it needed its own override to fix). */
.brackets {
  position: absolute;
  inset: calc(var(--header-h) + 1.5rem) clamp(1rem, 3vw, 2.5rem) clamp(1rem, 3vw, 2.5rem);
  pointer-events: none;
}
/* Interior heroes are 58svh, so the copy block sits much closer to the top and
   the vertical arm ran through the eyebrow. Drop the marks on short heroes. */
.hero--short .brackets { display: none; }
@media (max-width: 640px) {
  /* Decorative only — at phone width it just crowds the copy. */
  .brackets { display: none; }
}
.brackets::before,
.brackets::after {
  content: '';
  position: absolute;
  width: 64px;
  height: 64px;
  border: 1px solid var(--line-strong);
}
.brackets::before { top: 0; left: 0; border-right: 0; border-bottom: 0; }
.brackets::after { bottom: 0; right: 0; border-left: 0; border-top: 0; }

/* ==========================================================================
   media
   ========================================================================== */

.figure { margin: 0; }
.figure img { width: 100%; border-radius: var(--radius-2); }
.figure figcaption {
  margin-top: 1rem;
  text-align: center;
  font-size: var(--fs-small);
  color: var(--faint);
}

/* The reference's most-used ratio. */
.ratio-cine { aspect-ratio: 21 / 9; object-fit: cover; }
.ratio-wide { aspect-ratio: 16 / 9; object-fit: cover; }
.ratio-tall { aspect-ratio: 4 / 5; object-fit: cover; }
/* Two more, named by their numbers rather than by a mood: 3:2 is the classic
   editorial landscape and sits between wide and square; 4:5 is the portrait
   crop — the same shape .ratio-tall already gives, kept under its own name
   because a layout asking for a portrait plate should not have to know that
   "tall" happens to mean 4:5 today. Both are declared in RATIOS in html.mjs
   too, or img() would reserve the intrinsic box and the page would shift when
   CSS lands. */
.ratio-3-2 { aspect-ratio: 3 / 2; object-fit: cover; }
.ratio-4-5 { aspect-ratio: 4 / 5; object-fit: cover; }

@media (max-width: 640px) {
  /* 21:9 is unreadably thin on a phone. */
  .ratio-cine { aspect-ratio: 16 / 10; }
}

/* ==========================================================================
   split — image one side, copy the other; alternates down the page
   ========================================================================== */

.split {
  display: grid;
  gap: clamp(2rem, 4vw, 4.5rem);
  align-items: center;
}
@media (min-width: 900px) {
  .split { grid-template-columns: 1fr 1fr; }
  .split--flip .split__media { order: 2; }
}

/* ==========================================================================
   grids & cards
   ========================================================================== */

.grid { display: grid; gap: var(--grid-gutter); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); }
.grid--4 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); }
/* A category with only two products (e.g. /radios/) would otherwise stretch
   both cards to ~760px — 2.7x the same card on a full category page, with the
   body collapsing to a strip under a giant photo. Cap the track so a short row
   keeps card-sized cards and simply leaves space. */
@media (min-width: 1024px) {
  .grid--4 { grid-template-columns: repeat(auto-fit, minmax(14rem, 22rem)); }
}

/* Capped at 3 rather than auto-fit: with five categories, auto-fit lands on
   four columns at desktop and leaves a single orphan on the second row. Three
   columns give a deliberate 3 + 2. */
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); }
@media (min-width: 1024px) {
  .grid--3 { grid-template-columns: repeat(3, 1fr); }
}

/* --------------------------------------------------------------------------
   grids that close
   --------------------------------------------------------------------------

   An `auto-fit` grid leaves whatever it leaves. With five items that is one
   orphan against the left edge and four cells of nothing beside it — the empty
   cell the page was called out for. These two utilities are for sets whose
   COUNT IS KNOWN, and they are built so the last row is always full.

   No script: `:nth-child` can count, and a layout that needs JavaScript to
   avoid a hole is a layout that has a hole until the JavaScript runs. */

/* .grid--5 — exactly five children.
   >=1280: five equal columns, the honest form.
   900-1279: five equal columns would be 220px each, too narrow to hold a card.
     So the row becomes a grid of six units split 2+2+2 over 3+3 — three items
     on the first line, two on the second, both lines full to the edge. Six is
     the only small unit count divisible by both 3 and 2.
   <900: one column. */
.grid--5 { grid-template-columns: 1fr; }
@media (min-width: 900px) {
  .grid--5 { grid-template-columns: repeat(6, 1fr); }
  .grid--5 > :nth-child(-n + 3) { grid-column: span 2; }
  .grid--5 > :nth-child(n + 4) { grid-column: span 3; }
}
@media (min-width: 1280px) {
  .grid--5 { grid-template-columns: repeat(5, 1fr); }
  /* Both nth-child selectors have to be answered by name. `.grid--5 > *` looks
     like it undoes them and does not: a class plus a pseudo-class outranks a
     class plus a universal, so the spans survived into the five-column range
     and laid 12 units across 5 tracks — three rows, one orphan. Measured, not
     reasoned about: the fixture probe caught it. */
  .grid--5 > :nth-child(-n + 3),
  .grid--5 > :nth-child(n + 4) { grid-column: auto; }
}

/* .grid--3-close — three columns, and a short last row stretches to fill.
   The last child spans whatever the row has left: two cells when the total
   leaves a remainder of 1, one extra cell when it leaves 2.

   `:last-child:nth-child(3n+1)` reads "the last child is also the 1st, 4th,
   7th…", which is only true when the total is 3n+1 — the count is inferred
   from the position of the end. Same for 3n+2.

   LIMIT: this covers remainder 1 and remainder 2, which is every case except
   an exact multiple of 3 (where nothing is needed). The one shape to know
   about is a SINGLE child: 1 is 3n+1, so a lone item goes full width. That is
   right for a callout and wrong for a lone card — use .grid--3 for a set that
   can legitimately be one item long. */
.grid--3-close { grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); }
@media (min-width: 1024px) {
  .grid--3-close { grid-template-columns: repeat(3, 1fr); }
  .grid--3-close > :last-child:nth-child(3n + 1) { grid-column: span 3; }
  .grid--3-close > :last-child:nth-child(3n + 2) { grid-column: span 2; }
}

/* .grid--fit — a set of ONE TO FOUR items whose count varies but is known to
   the DOM. The last row is always full, at every width, without a spanning
   child.

   The defect it fixes, measured on /products/ye-tt56/ and /about/: `.grid--4`
   is `repeat(auto-fit, minmax(min(100%, 14rem), 1fr))`, and auto-fit derives
   its column count from the space available, not from the number of items. At
   any width between about 660 and 1023px that arithmetic yields three tracks —
   so four sibling cards land 3 + 1 with two empty cells beside the orphan, and
   the four About principles do the same thing on the same widths. It is the
   `emptyCells` defect, one page over from where it was first found.

   `.grid--3-close` closes a short row by making the last child span the rest
   of it, which is right for a text block and wrong for a card: a product photo
   at three tracks wide beside two at one is not a grid, it is an accident with
   a caption. So this one asks for fewer columns instead of a wider cell, and it
   reads the count out of the DOM with `:has()` rather than being told:

     1 item   one track, capped so a lone card stays card-sized
     2 items  1 col below 600, 2 above
     3 items  1 col below 600, 3 above — three is what three items want, and a
              stack of three photo cards on a tablet is a scroll marathon
     4 items  1 / 2 / 4, so it is 2 + 2 through the whole middle band

   No script and no build-time class: the count is already in the markup, and a
   layout that needs JavaScript to avoid a hole has a hole until it runs.

   WHERE `:has()` IS ABSENT (Safari < 15.4, Firefox < 121), THIS STILL CLOSES.
   The base rule below carries the whole layout on its own — ONE column, at
   every width — and every `:has()` rule is a refinement that only ever adds
   columns. So an old browser gets a single stack: fewer columns than intended,
   never a hole, never a cell that renders empty and never an item that fails to
   render. That is the direction this had to fail in, and the `@supports
   selector()` gate below is what states it rather than leaving it to the
   accident that an unparseable selector drops its own rule. A browser with no
   `@supports selector()` at all reads the condition as false and lands in the
   same safe place. */
.grid--fit { grid-template-columns: 1fr; }
@supports selector(:has(a)) {
  .grid--fit:not(:has(> :nth-child(2))) { grid-template-columns: minmax(0, 22rem); }
  @media (min-width: 600px) {
    .grid--fit:has(> :nth-child(2)) { grid-template-columns: repeat(2, 1fr); }
    .grid--fit:has(> :nth-child(3)):not(:has(> :nth-child(4))) {
      grid-template-columns: repeat(3, 1fr);
    }
  }
  @media (min-width: 1024px) {
    .grid--fit:has(> :nth-child(4)) { grid-template-columns: repeat(4, 1fr); }
  }
}

/* .row-3 — three equal items on one line, a column on a phone. For the audit
   seals, where three is the count and always will be. `flex: 1 1 0` rather
   than `1 1 auto`: equal thirds regardless of how long each label runs, so the
   three read as one set. */
.row-3 { display: flex; gap: clamp(1.25rem, 2vw, 2rem); }
.row-3 > * { flex: 1 1 0; min-width: 0; }
@media (max-width: 599px) {
  .row-3 { flex-direction: column; }
}

/* .chips — a wrapping line of .chip. `.tags` is the same shape for the square
   .tag; this one exists because chips are pills and carry their own baseline,
   and because naming the container after what is in it is how every other
   utility here is named. */
.chips { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }

.card {
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-2);
  overflow: hidden;
  transition: border-color 0.4s var(--ease);
}
.card:hover { border-color: var(--line-strong); }
.band--surface .card { background: var(--ground); }

.card__media { overflow: hidden; background: var(--ground); }
.band--surface .card__media { background: var(--surface); }
.card__media img { width: 100%; transition: transform 0.6s var(--ease); }
.card:hover .card__media img { transform: scale(1.03); }

.card__body { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); flex: 1; }

/* Light-backdrop renders get a light card so the photo meets its own ground
   instead of floating as a bright slab on the dark tile.

   A TOKEN SWAP, like .band--light, and for the same reason: nine per-element
   colour overrides were nine chances for the tenth element to be missed. The
   plate is deliberately --bg-plate and not --bg-light-0: the light ramp is warm
   and the render's studio white is neutral, so a warm card behind it shows the
   seam. Every pair below is measured against --bg-plate — fg-light-1 14.91:1,
   fg-light-2 7.69:1, fg-light-3 4.96:1, accent-light 4.94:1. */
.card[data-backdrop='light'],
.band--surface .card[data-backdrop='light'] {
  --bg-0: var(--bg-plate);
  --bg-1: var(--bg-plate);
  --bg-2: var(--bg-plate);
  /* Same measurement as .band--light above: the chip's ink is Lc 69.3 on
     --bg-light-2 and Lc 82.5 on --bg-light-1. */
  --bg-3: var(--bg-light-1);
  --fg-1: var(--fg-light-1);
  --fg-2: var(--fg-light-2);
  --fg-3: var(--fg-light-3);
  --ground: var(--bg-plate);
  --surface: var(--bg-plate);
  --white: var(--fg-light-1);
  --muted: var(--fg-light-2);
  --faint: var(--fg-light-3);
  --copper: var(--accent-light);
  --bronze: var(--accent-light-hover);
  --accent: var(--accent-light);
  --accent-rgb: var(--accent-light-rgb);
  --line-soft: var(--line-light-soft);
  --line: var(--line-light);
  --line-strong: var(--line-light-strong);
  background: var(--bg-plate);
  border-color: var(--line-light);
  color: var(--fg-light-2);
}
.card[data-backdrop='light'] .card__media,
.band--surface .card[data-backdrop='light'] .card__media { background: var(--bg-plate); }
.card[data-backdrop='light'] .h4 { color: var(--fg-light-1); }
.card[data-backdrop='light'] .small,
.card[data-backdrop='light'] .card__meta,
.card[data-backdrop='light'] .faint { color: var(--fg-light-2); }
.card[data-backdrop='light'] .swatch { border-color: var(--line-light-strong); }
.card[data-backdrop='light']:hover { border-color: var(--line-light-strong); }

.card__code {
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  font-family: var(--font-structure);
  color: var(--fg-3);
  font-variant-numeric: lining-nums tabular-nums;
}
.card__meta { font-size: var(--fs-small); color: var(--faint); margin-top: auto; padding-top: 0.75rem; }

/* Category card: the lifestyle render carries it, so the copy stays quiet. */
.cat-card__count { font-size: var(--type-caption-size); color: var(--fg-3); }
.cat-card__codes {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  font-size: 0.6875rem;
  color: var(--faint);
  letter-spacing: 0.08em;
}

/* Finish swatches — real hex values pulled from the catalogue. */
.swatches { display: flex; gap: 0.4rem; align-items: center; }
.swatch {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 1px solid rgb(var(--fg-1-rgb) / 0.25);
}

/* ==========================================================================
   spec & data
   ========================================================================== */

.features { display: grid; gap: 0.85rem; }
/* flex, not grid. The row is a bullet and a span, and the bullet is a
   `::before` — a real grid item, but one the `emptyCells` probe cannot see,
   because it counts element children. So a two-track grid holding one element
   read as a row with a hole in it, ten times over, on all 35 product pages.
   The layout is a bullet at a fixed width beside text that takes the rest,
   which is what flex describes anyway; grid was reaching for a track sizing
   nothing needed. */
.features li {
  display: flex;
  gap: 0.85rem;
  align-items: start;
  font-size: var(--fs-small);
  line-height: 1.55;
}
.features li > * { min-width: 0; }
.features li::before {
  content: '';
  flex: 0 0 5px;
  width: 5px;
  height: 5px;
  margin-top: 0.55em;
  border-radius: 50%;
  /* Not the accent: a bullet is punctuation, and copper is for things you can
     act on. */
  background: var(--fg-3);
}

.spec {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-small);
  font-variant-numeric: tabular-nums;
}
.spec th, .spec td { text-align: left; padding: 0.85rem 0; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
.spec th { color: var(--faint); font-weight: 400; width: 40%; }
.spec td { color: var(--white); }
.spec tr:last-child th, .spec tr:last-child td { border-bottom: 0; }

/* stats */
.stat { border-top: 1px solid var(--line); padding-top: 1.25rem; }
/* Four figures, four columns. `.grid--4` cannot do it: its 22rem cap exists so
   a two-card category row keeps card-sized cards, but auto-fit counts
   repetitions against a definite max, so 1232px of container yields three
   tracks of 22rem and strands the fourth figure alone on a second row. A row
   of stats is a fixed set, so it gets a fixed count. */
.stat-row { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 900px) { .stat-row { grid-template-columns: repeat(4, 1fr); } }
/* A figure is the structural voice, not the editorial one: Archivo 600 at the
   h2 step. Tabular AND lining, so a row of four figures shares one grid and
   sits on the cap line. The serif carried these until 2026-08-30 and was the
   reason the display face was on eleven blocks a page. */
/* Colour comes from the G3 figure rule beside .hp-cap__n. */
.stat__n {
  font-family: var(--font-structure);
  font-size: var(--fs-stat);
  font-weight: 600;
  letter-spacing: -0.015em;
  line-height: 1;
  font-variant-numeric: lining-nums tabular-nums;
}
.stat__label { margin-top: 0.6rem; font-size: var(--fs-small); color: var(--faint); }

/* --------------------------------------------------------------------------
   .plaque — a numbered block of text under a hairline
   --------------------------------------------------------------------------

   The shape three interior pages were already drawing by hand: the About
   principles, the Capabilities steps and the Recognition plaques on /quality/
   were each a `.stat` wrapper holding a `.card__code`, an `.h4` and one or two
   `.small.faint` paragraphs — with `style="margin-top:0.75rem"` written out
   four times per card, three times over, in three functions. Three copies of
   one component drift, and they had: 0.75rem here, 0.5rem there, and the
   Recognition card carrying a second faint paragraph at 0.25.

   It is the ramp and nothing else: the numeral at the caption step in the
   structural family, the title at .h4 (the h3 family and weight at a fixed
   17px, because a card title that matches the section heading above the grid
   is competing with it), the body at body colour, the meta at --fg-3. The
   hairline above is `--line` — structure, not a seam. */
.plaque { border-top: 1px solid var(--line); padding-top: var(--space-4); }
/* The numeral is an index, not an action: --fg-3, and copper never touches it.
   Tabular so "01" and "iv" both sit on the same left edge down a row. */
.plaque__n {
  display: block;
  font-family: var(--font-structure);
  font-size: var(--type-eyebrow-size);
  font-weight: var(--type-eyebrow-weight);
  letter-spacing: var(--type-eyebrow-track);
  text-transform: uppercase;
  color: var(--fg-3);
  font-variant-numeric: lining-nums tabular-nums;
}
.plaque__title { margin-top: var(--space-3); }
.plaque__body { margin-top: var(--space-2); font-size: var(--fs-small); color: var(--fg-2); }
/* Issuer and year are reference, one step back from the body they follow. The
   year is a figure and gets the figure grid. */
.plaque__issuer { margin-top: var(--space-3); font-size: var(--fs-small); color: var(--fg-3); }
.plaque__year {
  margin-top: var(--space-1);
  font-size: var(--fs-small);
  color: var(--fg-3);
  font-variant-numeric: lining-nums tabular-nums;
}
.principles, .plaques { margin-top: var(--space-5); }
.plaques__note { margin-top: var(--space-5); }
.siblings { margin-top: var(--space-5); }

/* .callout — a bordered aside carrying one control. Used once, on /contact/,
   for the catalogue download beside the direct-contact table. It was three
   inline styles on a bare <div>. */
.callout {
  margin-top: var(--block-gap);
  border: 1px solid var(--line);
  border-radius: var(--radius-2);
  padding: var(--space-5);
}
.callout__label { margin-bottom: var(--space-2); }
.callout__cta { margin-top: var(--space-4); }

/* certification / audit lists */
.tags { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.tag {
  padding: 0.45rem 0.9rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-1);
  font-family: var(--font-structure);
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  color: var(--fg-1);
}

/* --------------------------------------------------------------------------
   certification / audit register
   --------------------------------------------------------------------------

   Eleven acronyms in two groups, and it is what /quality/ is built on since
   2026-08-30 — it was written in the foundations round and nothing used it.
   `.tags`, the pill treatment it replaced, does not survive this many: eight
   pills wrap into a ragged blob, the eye gets no line to follow, and the two
   columns it sat in left a 600px void under the shorter one. So they are set
   as a register instead. Each acronym takes a row that carries a hairline;
   with the rows stacked the hairlines read as one continuous rule, which is
   what makes the block scan as a list rather than as loose chips.

   And every mark now says what it IS. The definitions were in
   `facts.certScopes` and `certScope()` all along, unread: a buyer who does not
   already know CP65 or FCCA learns nothing from four letters in a box.

   Hierarchy is order plus label, not size: what the PRODUCT is tested to comes
   first, what the FACTORY was audited against second, each named by its own
   eyebrow with the count beside it in copper. Both groups get the same cell
   treatment on purpose — they are one register in two parts, and styling the
   second one down would suggest its audits are the lesser claim. */

/* The first group opens the band; the band's own --section-y is the space
   above it. The two groups are one register, so they sit closer to each other
   than the register sits to the band edge. */
.certs + .certs { margin-top: clamp(2.25rem, 1.5rem + 2vw, 3.5rem); }
/* The paragraph between the label and the list: body colour, lead measure. It
   is prose, and prose set to the full 1280px of .wrap--mid is a paragraph
   nobody finishes a line of. */
.certs__intro { margin-top: var(--space-3); max-width: var(--measure-lead); color: var(--fg-2); }
/* One line of type, not a label with a figure bolted on. The count keeps the
   label's own family, size and tracking and is separated by a middot; only the
   colour changes, which is how every other emphasis on this site is made. */
.certs__label {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.55rem;
  margin-bottom: 0;
}
/* Decorative, but it is still a rendered glyph and the audit scores it as one:
   at 0.45 alpha it measured 2.62:1. It inherits the label's own copper. */
.certs__sep { color: inherit; }
/* G3 — the counter is copper. It inherits the label's copper rather than
   overriding it back to --fg-1: "8" and "standards" are one line of type, and
   the count was the only word in it wearing a different colour. */
.certs__count { color: inherit; font-variant-numeric: lining-nums tabular-nums; }

/* One row per mark, full width of its column.

   The five-column grid this replaces could not survive an odd count: five
   audits in a two-column phone grid left "ISO 14001" alone on the last row with
   the shared hairline running on into empty space, which reads as a broken rule
   rather than as the end of a list. A row holding one mark and its scope is
   always full; a short final column simply stops.

   Two columns from 760px up: the scopes are 20–40 characters, so three sets
   them ragged and one leaves a 600px gap. The column gap is load-bearing —
   with `gap: 0` the hairlines of adjacent columns fuse into one rule. */
.certs__list {
  display: grid;
  grid-template-columns: 1fr;
  column-gap: clamp(2rem, 4vw, 4.5rem);
  margin-top: 1.5rem;
}
/* Two columns only when two columns can be FULL. `:nth-child(2n):last-child`
   reads "the last row is also an even one", which is only true of an even
   count — eight product certifications yes, three factory audits no. Three
   marks in two columns leaves the last row half empty with a hairline running
   on into nothing, which is the same defect the five-column version of this
   block was rebuilt to fix.
   The odd list keeps one column, and `.cert`'s own fixed code column (below)
   is what stops a single-column row from putting its scope 340px away from
   its acronym.

   WITHOUT `:has()` both lists stay one column — which is the layout the odd one
   already gets, and the layout `.certs__list` is declared with above. Every
   mark and every scope is still on the page, each row still full: the register
   reads as a longer single column instead of a shorter double one. No mark
   disappears and no row is left half-ruled, which is the failure this block was
   rebuilt to fix in the first place. */
@supports selector(:has(a)) {
  @media (min-width: 760px) {
    .certs__list:has(> :nth-child(2n):last-child) { grid-template-columns: repeat(2, 1fr); }
  }
}
/* Mark left, meaning in a second column starting at the same x on every row —
   the shape of the `.spec` tables elsewhere on the site. Justifying the scope
   right would look tidier and scan worse: this band is read asking "is my
   market here", and that is answered down a column, not across a 250px void. */
/* A fixed code column, not 27%: the two lists on this page have different
   column counts — eight marks in two columns, three audits in one — and a
   percentage would start the scopes at 158px in one and at 340px in the
   other, on the same page, under the same eyebrow. 10rem holds both. */
.cert {
  display: grid;
  grid-template-columns: minmax(6.5rem, 10rem) 1fr;
  align-items: baseline;
  gap: 0.15rem 1.5rem;
  border-bottom: 1px solid var(--line);
  padding: 0.85rem 0;
}
/* The acronym leads the row, so it takes the structural family — the same
   voice the site sets every label and every figure in. Tabular, because
   CE-LVD and CP65 sit in one column and half of them carry digits. */
.cert__code {
  font-family: var(--font-structure);
  font-weight: 500;
  font-size: 0.9375rem;
  letter-spacing: 0.06em;
  color: var(--fg-1);
  font-variant-numeric: lining-nums tabular-nums;
  white-space: nowrap;
}
/* The scope is the answer to "what is this", so it is set as running text
   rather than as another tracked label — and held back one step in colour so
   the acronym still leads the row. */
.cert__scope {
  font-size: var(--fs-small);
  color: var(--fg-3);
}
@media (max-width: 460px) {
  /* Below this the two halves cannot share a line without the scope breaking
     into three words per row. Stack them. */
  .cert { display: block; }
  .cert__scope { display: block; margin-top: 0.2rem; }
}

/* The seal row and the note about evidence, on one line: the three marks are
   the pictures of what the register just listed, so they belong under it
   rather than beside the prose. */
.certs__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 2rem;
  /* space-6, not --block-gap: the foot belongs to the register above it, and
     --block-gap (48–96px) is the distance between two blocks that do not. */
  margin-top: var(--space-6);
  border-top: 1px solid var(--line-soft);
  padding-top: var(--space-6);
}
.certs__note { max-width: 34ch; margin: 0; }

/* --------------------------------------------------------------------------
   .seal-row — a certification mark with its own name beside it
   --------------------------------------------------------------------------

   One component, two placements: the footer's compliance line on all 94 pages
   and the register's foot on /quality/. `scripts/build-badges.mjs` writes the
   three PNGs white-on-transparent, so no filter trickery is needed — but they
   run 1:1 (ISO), 2.8:1 (BSCI) and 1.25:1 (TÜV), and a shared WIDTH would set
   ISO at 48px tall and BSCI at 17. Height is the shared dimension; width is
   left free.

   0.9, not the 0.7 the old `.badges` rule held them at. These are evidence. A
   seal you have to squint at is decoration with a certificate number.

   (`.hp-seal` on the home page is the same idea drawn a second time, at 72px
   and stacked rather than in line. Collapsing the two is the home frontline's
   call, not this one's — the markup is theirs.) */
.seal-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3) var(--space-5); }
.seal { display: inline-flex; align-items: center; gap: 0.6rem; }
.seal img { height: 48px; width: auto; max-width: 100%; opacity: 0.9; }
.seal__name {
  font-family: var(--font-structure);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  color: var(--fg-2);
  white-space: nowrap;
}

/* --------------------------------------------------------------------------
   partner logo strip
   -------------------------------------------------------------------------- */

/* Gestalt: one contained region, one colour, one opacity, one rhythm — ten
   unrelated marks have to read as a single group rather than ten competing
   logos. Similarity does the grouping (all white, all held back), proximity
   does the rhythm (equal cells, equal gaps), and continuity comes from each
   mark being centred on a shared axis inside its own cell.

   Flex-with-fixed-cells, not grid: with ten items a wrapped grid strands
   orphans against the left edge, while centred flex wrapping keeps every row —
   including a partial one — symmetrical about the page axis. */
/* `.logo-strip` is the name; `.partners` is kept as a literal alias so the
   markup that already ships (the home strip) is not broken by the rename, and
   so the two can never drift into two different strips. */
.logo-strip, .partners {
  /* Was clamp(15px, 1.55vw, 21px) — 21px of optical height for the proof that
     ten brands ship this factory's hardware, which made it the smallest thing
     on the page. --logo-strip-h is the base; --s still scales each mark by its
     own optical weight from the manifest. */
  --logo-strip-h: 32px;
  display: grid;
  grid-template-columns: repeat(5, auto);
  justify-content: space-between;
  align-items: center;
  justify-items: center;
  row-gap: clamp(1.15rem, 2.2vw, 1.9rem);
  column-gap: clamp(1.5rem, 3vw, 3rem);
}

/* Equal 1fr cells would have undone the optical scaling: ten identical boxes
   with very different ink widths inside read as ragged air, not rhythm. `auto`
   columns size to their own content instead, so the gaps stay even where the
   eye measures them — between the marks — while the 5x2 structure stays fixed.
   Left to a flex wrap the row broke 8+2, which is the worst split of ten. */
/* Full strength — and GREY, not white. 0.55 opacity read as a watermark and
   0.72 still read as "we would rather you did not look at this", so the marks
   stay at opacity 1 and the holding-back is done to the INK instead: the marks
   are already flattened to one white by build-badges, so a brightness() is a
   multiply on a flat white and nothing else. --logo-strip-tint is 0.546, which
   lands the marks on the neutral grey carrying --fg-3's own luminance — the
   weight of meta text, one plane below the headline they sit under.

   Why a filter and not an opacity, twice over: the logoStrip probe requires a
   computed opacity of at least 0.9, and an opacity would let the band show
   through the marks, which on a photograph is ten different greys instead of
   one. Similarity still does the grouping — one colour, one rhythm — it is
   simply a quieter colour than white. */
.logo-strip img, .partners img {
  filter: var(--logo-strip-tint);
  width: auto;
  /* The optical factor scales DOWN from the base, so the lightest mark
     (Walmart, --s 0.658) landed at 21px — under the 28px legibility floor the
     home probe enforces. max() keeps the optical ordering between marks that
     sit above the floor and simply refuses to let any mark fall below it:
     losing a little relative nuance beats an illegible proof mark. */
  height: max(28px, calc(var(--logo-strip-h) * var(--s, 1)));
  opacity: 1;
}

/* 5 per row down to 900px; two below it — the only other divisor of ten that
   leaves no orphan row. Ten marks cannot be legible in one screen-width row on
   a phone, so the strip gets taller rather than thinner. */
@media (max-width: 899px) {
  .logo-strip, .partners {
    --logo-strip-h: 24px;
    grid-template-columns: repeat(2, auto);
    justify-content: center;
    column-gap: clamp(1.75rem, 7vw, 3rem);
    row-gap: 0.85rem;
  }
  /* Same floor logic, phone scale: 20px minimum rendered height. */
  .logo-strip img, .partners img {
    height: max(20px, calc(var(--logo-strip-h) * var(--s, 1)));
  }
}

/* --------------------------------------------------------------------------
   the marquee the strip becomes when motion is welcome
   -------------------------------------------------------------------------- */

/* Read the default first: OUTSIDE the media query below, this is the strip that
   shipped. `.logo-marquee` and its track are plain blocks, the second copy is
   not rendered, and the first copy is the 5x2 grid above, untouched. That is
   deliberately the base state and not an override — `prefers-reduced-motion:
   reduce` and a browser that has never heard of the query both land on it, and
   neither of them needs a rule of its own.

   `overflow: hidden` is unconditional even so. It is what makes the clip real
   rather than merely masked (a browser without mask support still gets a
   window, not a row running off the page), and it is what tells the `overflow`
   probe that a strip wider than the viewport is a scroller by intent and not a
   layout bug. */
.logo-marquee {
  overflow: hidden;
  /* The row's height is the tallest mark — Amazon, whose optical factor is
     1.0, so it renders at exactly --logo-strip-h. Every mark carries its real
     width/height attributes, so the box is reserved before the first byte of
     PNG arrives; this floor is what holds the band open if one somehow never
     does. No CLS from either direction. */
  min-height: 32px;
}

/* The duplicate exists for the loop and for nothing else. Without motion there
   is no loop, so there is no duplicate — this is the line that makes the
   reduced-motion page identical to the one that shipped. */
.logo-marquee__track > .logo-strip[aria-hidden] { display: none; }

@media (max-width: 899px) { .logo-marquee { min-height: 24px; } }

@media (prefers-reduced-motion: no-preference) {
  /* The fade. Two properties, never one: the unprefixed `mask-image` is the
     standard and the `-webkit-` twin is what Safari still answers to, and a
     marquee whose marks pop in at a hard edge is the thing this is for. */
  .logo-marquee {
    -webkit-mask-image: var(--logo-marquee-mask);
    mask-image: var(--logo-marquee-mask);
  }

  /* `width: max-content` so the track is as wide as its two copies rather than
     as wide as the band; `translate3d` rather than `translateX` so the whole
     lap runs on the compositor and never asks for layout. */
  .logo-marquee__track {
    display: flex;
    width: max-content;
    animation: logo-marquee var(--logo-marquee-duration) linear infinite;
  }

  /* The strip stops being a grid here and becomes one row. The gap is carried
     TWICE — as the flex gap inside a copy and as the copy's own trailing
     padding — and that is the whole trick of the seamless loop: each copy is
     then (marks + gaps + one trailing gap) wide, the track is exactly two of
     those, and -50% of the track is one copy plus one gap. A flex `gap` on the
     track instead would put half a gap at the seam. */
  .logo-marquee__track > .logo-strip {
    display: flex;
    align-items: center;
    gap: var(--logo-marquee-gap);
    padding-inline-end: var(--logo-marquee-gap);
    flex: 0 0 auto;
  }
  .logo-marquee__track > .logo-strip[aria-hidden] { display: flex; }

  /* `.hpage .logo-strip img { max-width: 100% }` further down keeps a 9:1 mark
     like Blaupunkt inside its GRID cell at 1024. In the marquee there is no
     cell: the track is max-content and the strip's width is the sum of its own
     marks, so a percentage there resolves against a width the marks define and
     squeezes the widest of them. `none` is the honest value in a row whose
     whole job is to be wider than the window. */
  .hpage .logo-marquee__track .logo-strip img { max-width: none; }

  /* Pause under the pointer, and under the keyboard: a reader who wants to
     read a mark stops the row by looking at it. :focus-within costs nothing
     today (the marks are images, not controls) and is right the day one of
     them becomes a link. */
  .logo-marquee:hover .logo-marquee__track,
  .logo-marquee:focus-within .logo-marquee__track { animation-play-state: paused; }
}

@keyframes logo-marquee {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

/* .band--tight's padding now comes from --section-y-tight, with the rest of
   the band rules at the top of the file. Only the hairline is left here. */
.band--tight { border-top: 1px solid var(--line-soft); }

/* ==========================================================================
   header
   ========================================================================== */

.header {
  position: fixed;
  inset: 0 0 auto;
  z-index: 100;
  transition: background-color 0.4s var(--ease), border-color 0.4s var(--ease),
              backdrop-filter 0.4s var(--ease);
  border-bottom: 1px solid transparent;
}
/* Category heroes are bright lifestyle renders, and an unscrimmed transparent
   header left the nav grey-on-bright. A permanent top gradient keeps the nav
   legible over any hero without making the header look like a solid bar. */
/* Three stops, and every number is solved rather than chosen. `contrastOnImage`
   measures the real pixels behind the real glyphs, and against the /quality/
   hero at 1440 the two-stop 0.75 → 0 ramp put the nav links at 4.03:1 and the
   switchboard number at 3.09:1 — both under AA, on every page with a hero.
   The arithmetic: the row's centre line sits at 48px of the gradient's 144px,
   i.e. 33%. --fg-2 (#B1B0AD) needs the composited pixel behind it at 68 or
   darker to clear 4.5:1, and the worst case a hero can present is a blown
   window at 255 — 68 = a·10 + (1−a)·255 solves to a = 0.76. So the ramp holds
   0.95 at the top and is still 0.72 at 45%, which puts 0.78 under the row, and
   is back to nothing by the time it leaves the header: the hero is a
   photograph again 3rem down. A two-stop 0.75 → 0 ramp was 0.50 there, and the
   nav measured 4.03:1 over a mid-grey and 3.39:1 once it moved right into the
   bright half of the /quality/ plate. */
.header::before {
  content: '';
  position: absolute;
  inset: 0 0 -3rem;
  background: linear-gradient(
    to bottom,
    rgb(var(--scrim-rgb) / 0.95) 0%,
    rgb(var(--scrim-rgb) / 0.72) 45%,
    rgb(var(--scrim-rgb) / 0) 100%
  );
  pointer-events: none;
  transition: opacity 0.4s var(--ease);
}
.header[data-scrolled='true']::before { opacity: 0; }
.header__inner { position: relative; }
.header[data-scrolled='true'] {
  background: rgb(var(--scrim-rgb) / 0.9);
  /* NO `-webkit-backdrop-filter` HERE, AND IT IS A DECISION. Safari carried
     this property behind the prefix until 18, so the pair is the reflex — but
     adding it was measured and it MOVES PIXELS IN CHROME: two full-page
     captures of /quality/ at 1440, identical in every other respect, differ
     across the whole 145px header band once the alias is present (5.145 pixels
     past a delta of 8, all of them glyph and logo edges — the header's
     composited layer is rebuilt slightly differently). Removing it again makes
     the capture byte-identical to the one before this round.
     What the prefix buys is small in the other direction: the scrim under the
     blur is 0.9 opaque, so a Safari that ignores this line loses a blur and
     keeps every bit of the legibility the scrim was solved for. A sub-pixel
     change in the browser the whole visual system is measured in is not worth
     a decoration in the one that degrades cleanly without it. Revisit only
     with a screenshot pass to compare against. */
  backdrop-filter: blur(12px);
  border-bottom-color: var(--line-soft);
}
/* The bar is sized from the lockup, not the other way round: --header-h is
   --logo-h plus 12px of air top and bottom. Everything that has to agree with
   the header's height reads this token — the burger panel's inset, its
   max-height, the viewfinder brackets — so there is one number to change and
   nothing left behind to disagree with it. */
.header__inner {
  display: flex;
  align-items: center;
  gap: 1.5rem;
  height: var(--header-h);
  transition: height 0.4s var(--ease);
}
.header[data-scrolled='true'] .header__inner { height: var(--header-h-scrolled); }
.header__logo { color: var(--white); display: flex; align-items: center; flex-shrink: 0; }
/* Full lockup. This height sizes the BLADES; the letterforms come out at
   almost exactly half of it — measured, not estimated: in a 659.69-unit
   viewBox the wordmark's ink runs y 157.9 to 487.4, and the viewBox has no
   padding to crop, so the only lever on the name's size is this number.

   72px, up from 56. The company's name is the one thing on this page a buyer
   has to leave with, and at 56 it was the fourth-largest object in its own
   header. 72 puts the wordmark at ~36px — the size a section heading is set
   at — and the bar grows to 96 to hold it rather than the mark being trimmed
   to fit a bar nobody measured.

   SCROLLED: the logo drops to 56px and the bar to 80px. Decided rather than
   inherited — at the top of the page the header is a masthead and can afford
   96px; once the reader is inside the page it is wayfinding, and 96px of
   fixed chrome over a 900px viewport is 11% of the screen permanently spent.
   56/80 is the old bar plus its own air, so nothing below it has to move. */
.header__logo svg { height: var(--logo-h); width: auto; transition: height 0.4s var(--ease); }
.header[data-scrolled='true'] .header__logo svg { height: var(--logo-h-scrolled); }
@media (max-width: 1024px) {
  /* 52px. The lockup is 2.85 wide for every 1 tall, so this is 148px of the
     342px usable at 390 — measured against the rest of the row (language pill,
     burger and three 24px gaps) it still leaves ~23px spare. The phone bar
     stays 72px, which is the number the burger panel was measured against and
     the only geometry on the site that was already right. */
  .header__logo svg,
  .header[data-scrolled='true'] .header__logo svg { height: var(--logo-h-mobile); }
}
/* The descriptor beside the lockup.
 *
 * "yiertek" alone says the brand and not the business, and this is a contract
 * manufacturer: a buyer arriving on a model page from a search result has to
 * be told what the company IS before anything else on the row matters.
 *
 * Structural family, caption size, --fg-3, uppercase — the caption voice, one
 * step below the nav in colour so it reads as an apposition to the name rather
 * than as a sixth link. NOT the eyebrow style: the eyebrow is copper and copper
 * on this site marks action, and a label that cannot be clicked must not wear
 * the colour of the things that can.
 *
 * The tracking is 0.16em, not the eyebrow's 0.24em: 0.24 is set for a 3–5
 * character label standing alone above a heading, and at 22 characters inside a
 * 96px bar it stops being a label and becomes a rule of letters.
 *
 * The hairline is what makes the pairing legible as one lockup-plus-apposition
 * instead of two things that happen to be near each other. It is --line (0.14),
 * the structural weight, and it is sized to the type rather than to the bar: a
 * full-height rule would read as a column divider.
 *
 * 1280px, the same threshold .header__phone uses, measured the same way: at
 * 1025 the row already carries the logo, five links, the magnifier, 中文 and
 * the Catalogue button, and a 22-character caption is what breaks it. It does
 * not appear in the burger panel either — the panel is a menu, and the footer
 * prints the same descriptor on every page. */
.header__descriptor {
  display: none;
  align-self: center;
  margin: 0;
  padding-left: var(--space-4);
  border-left: 1px solid var(--line);
  font-family: var(--font-structure);
  font-size: var(--type-eyebrow-size);
  font-weight: var(--type-eyebrow-weight);
  letter-spacing: 0.16em;
  line-height: 1.2;
  text-transform: uppercase;
  color: var(--fg-3);
  /* THREE rules, and all three are needed to make two lines impossible.
   *
   *  · `white-space: nowrap` forbids the line break itself.
   *  · `word-break: normal; overflow-wrap: normal; hyphens: none` close the
   *    doors that can break a word even under nowrap — a user stylesheet, a
   *    reader extension or a UA default that sets `overflow-wrap: anywhere`
   *    beats nowrap on an overflowing box, and a 22-character label in a full
   *    row is exactly the box that overflows.
   *  · `flex-shrink: 0` stops the flex row squeezing the box narrower than its
   *    own text. It was 1, which is what turns "the row is full" into "the
   *    descriptor is 40px wide with its text hanging out of it".
   *
   * The threshold below is the fourth, and it is the one that stops the row
   * needing any of this. */
  white-space: nowrap;
  word-break: normal;
  overflow-wrap: normal;
  hyphens: none;
  flex-shrink: 0;
}
/* :lang(zh) — 精密音响制造工厂 is eight full-width glyphs. Uppercase is a
   no-op on CJK and 0.16em of tracking on an em-wide glyph is a gap, not a
   rhythm, so the Chinese descriptor takes the same caption size with the
   tracking the ramp gives CJK everywhere else. */
:lang(zh) .header__descriptor { letter-spacing: 0.06em; }

/* 1360, not 1280 — and the number is measured, not chosen.
 *
 * With Archivo painted, the eight-item row needs 1197.2px of the 1232px the
 * 1280 content box gives it: 34.8px of slack, 2.8%. That is the number the
 * owner's browser spent. The row cannot wrap and the descriptor cannot break,
 * so what a wider face buys is not a second line but an overflowing header —
 * and 2.8% is inside the error bar of "which sans actually painted".
 *
 * Measured worst case, webfonts blocked and the three families re-pointed at
 * every fallback the machine has (scripts are in .local-state/…/tint):
 *
 *     Archivo (the real face)   row needs 1197.2  → viewport >= 1246
 *     container default         row needs 1192.9  → viewport >= 1241
 *     Liberation Sans           row needs 1194.0  → viewport >= 1242
 *     DejaVu Sans (widest)      row needs 1276.6  → viewport >= 1325
 *
 * 1360 clears the widest of those by 35px and is a real breakpoint on real
 * hardware (1366-wide laptops keep the descriptor; 1280 and 1300 give it up).
 * Below the threshold the descriptor is DISPLAY:NONE — it is not clipped, not
 * shrunk and not wrapped, so the row's failure mode is losing an apposition
 * rather than breaking a lockup.
 *
 * A media query measures the VIEWPORT, which on a desktop browser includes a
 * classic 15–17px scrollbar the row never gets to use — the error is in the
 * dangerous direction, and 15px out of 34.8px of slack is most of it. The
 * container query measures .header's own content box, which is the width the
 * row is really laid out in, so where it is supported it is the one that
 * decides. Both carry 1360: .header is `inset: 0 0 auto` with no padding of
 * its own, so its content box IS the usable page width and the two thresholds
 * mean the same thing — they simply measure it with different accuracy.
 *
 * The container is .header and NOT .header__inner, which is the box the flex
 * row actually sits in: container-type brings layout and style containment
 * with it, and .header__inner is the positioning parent of both the search
 * overlay and the burger panel. Containing the outer element buys the same
 * measurement without putting containment on the element two overlays are
 * anchored to. The 48px of --gutter padding is inside both numbers either way.
 *
 * The media query is the fallback and lives inside `@supports not (…)`, so the
 * two paths can never both apply and disagree. */
@supports not (container-type: inline-size) {
  @media (min-width: 1360px) { .header__descriptor { display: block; } }
}
@supports (container-type: inline-size) {
  .header { container-type: inline-size; container-name: header; }
  @container header (min-width: 1360px) { .header__descriptor { display: block; } }
}
/* When the search overlay opens it hides the row it covers; the descriptor sits
   under the field's left edge at 1280 and belongs in that set. */
.header__inner[data-search-open='true'] .header__descriptor { visibility: hidden; opacity: 0; }

.header__spacer { flex: 1; }

/* The switchboard number, as an item of the nav row. It is reference
   information a B2B buyer looks for first and the site used to answer three
   screens down, in the footer — it does NOT get to be a button, and it does
   not get a strip of its own above the nav.
   1520px, and it used to be 1280. The descriptor moved in beside the lockup
   and the row does not hold both below that — measured, not guessed: at 1440
   the eight items come to 1368px of a 1392px content box, which is 24px of
   slack and one font-rendering difference away from overflowing; at 1280 they
   come to 1354px of 1232px and the language pill was already wrapping "中文"
   onto two lines to make room.

   Of the two, the descriptor stays: "what is this company" is the question a
   buyer arriving on a model page from a search result asks before "what is
   their number", and the number is on every page in the footer — beside a
   phone glyph, in the block a buyer copies from — while the descriptor was
   nowhere in the chrome at all. Below 1025 the burger panel carries the
   number at 44px, as it always did. */
.header__phone {
  display: none;
  font-size: var(--fs-small);
  color: var(--muted);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  transition: color 0.3s var(--ease);
}
.header__phone:hover { color: var(--white); }
@media (min-width: 1520px) { .header__phone { display: inline-block; } }

/* The utility strip that used to sit above the nav is gone. It was a 34px
   hairline band holding the phone, the sales inbox and the QQ link, and it
   made the header two bars tall at the top of every page for three links a
   buyer reads once. The phone is now .header__phone in the nav row itself,
   the burger panel keeps all three at 44px, and the footer keeps the block
   you copy out of. */

/* Child selectors, not descendant: below 1024px the search component moves
   into this panel, and its result rows are anchors too — they must not inherit
   the nav link's padding, border and size. */
.nav { display: flex; gap: 2rem; }
.nav > a {
  font-family: var(--font-structure);
  font-weight: 500;
  font-size: var(--type-caption-size);
  color: var(--muted);
  white-space: nowrap;
  transition: color 0.3s var(--ease);
  position: relative;
}
.nav > a:hover, .nav > a[aria-current='page'] { color: var(--fg-1); }

.lang {
  display: inline-flex;
  align-items: center;
  /* 40px minimum so the language toggle is a comfortable touch target. */
  min-height: 40px;
  font-size: var(--fs-small);
  color: var(--copper);
  border: 1px solid var(--line);
  border-radius: var(--radius-1);
  padding: 0.45rem 0.9rem;
  transition: border-color 0.3s var(--ease), background-color 0.3s var(--ease);
}
.lang:hover { border-color: var(--accent); background: var(--hover-wash); }

.burger {
  display: none;
  background: none;
  border: 1px solid var(--line);
  border-radius: var(--radius-1);
  color: var(--white);
  padding: 0.5rem 0.75rem;
  cursor: pointer;
  /* Match .lang: both are icon-sized controls in the same row. */
  min-height: 40px;
  min-width: 40px;
  align-items: center;
  justify-content: center;
}

@media (max-width: 1024px) {
  .burger { display: block; }
  .nav {
    position: fixed;
    inset: var(--header-h) 0 auto;
    flex-direction: column;
    gap: 0;
    background: rgb(var(--scrim-rgb) / 0.98);
    /* Unprefixed, same reasoning as .header[data-scrolled='true'] above. At
       0.98 the panel is opaque and the blur is a refinement either way. */
    backdrop-filter: blur(12px);
    border-bottom: 1px solid var(--line-soft);
    padding: 1rem var(--gutter) 2rem;
    /* -120% left the panel's bottom edge peeking below y=0 on a short
       viewport: the panel is capped at 100svh − the header, and a 348px panel
       moved −417px still starts 72px down, so 2px of it showed above the
       header. Offsetting by its own height *plus* the top inset clears it at
       every height, which the percentage alone never did. The 8px on top of
       --header-h is the hairline plus rounding slack. */
    transform: translateY(calc(-100% - var(--header-h) - 8px));
    transition: transform 0.4s var(--ease);
    max-height: calc(100svh - var(--header-h));
    overflow-y: auto;
  }
  .nav[data-open='true'] { transform: translateY(0); }
  .nav > a { padding: 1rem 0; border-bottom: 1px solid var(--line-soft); font-size: 1rem; }
  /* The contact block below owns the hairline that closes the list, so the
     last link does not draw a second one 1.5rem above it. */
  .nav > a:last-of-type { border-bottom: none; }
  .header__cta { display: none; }

  /* Contact at the foot of the panel: one hairline, generous air, and two
     44px targets. Not styled as nav links — they are not destinations, and a
     phone number set at link weight in the middle of a menu reads as a sixth
     page. */
  .nav__contact {
    display: flex;
    flex-direction: column;
    margin-top: 1.25rem;
    padding-top: 0.5rem;
    border-top: 1px solid var(--line);
  }
  .nav__contact a {
    display: flex;
    align-items: center;
    /* One mark per channel. Three lines of 14px text in a menu are three
       strings of characters that look alike at a glance; the glyph is what
       tells a thumb which one is the phone. --fg-3 rather than the link's own
       colour, so the mark stays subordinate to the number it labels. */
    gap: 0.65rem;
    min-height: 44px;
    font-size: var(--fs-small);
    color: var(--muted);
    transition: color 0.3s var(--ease);
  }
  .nav__contact .icon { color: var(--fg-3); flex-shrink: 0; }
  .nav__contact a:active, .nav__contact a:hover { color: var(--white); }
  .nav__contact a:active .icon, .nav__contact a:hover .icon { color: var(--accent); }
  /* The WeChat row is a <span>, so none of the rules above reach it. It takes
     the same 44px row and the same gap — a menu whose fifth line sits on a
     different rhythm from the four above it reads as a mistake — and keeps its
     own non-link colour from `.channel--flat`. */
  .nav__contact .channel--flat {
    display: flex;
    align-items: center;
    gap: 0.65rem;
    min-height: 44px;
    font-size: var(--fs-small);
  }
}
@media (min-width: 1025px) { .nav__contact { display: none; } }

/* ==========================================================================
   model search
   ==========================================================================
   Two placements, one component. In the header it is a magnifier that expands
   over the header row — expanding *between* the nav and the language toggle
   would put a seventh item in a row that is already full at 1025px. On
   /products/ it is simply open. */

.search { position: static; flex-shrink: 0; }

.search__toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* Matches .lang and .burger — all three are icon-sized controls in one row. */
  min-width: 40px;
  min-height: 40px;
  padding: 0.5rem;
  background: none;
  border: 1px solid var(--line);
  border-radius: var(--radius-1);
  color: var(--muted);
  cursor: pointer;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease),
              background-color 0.3s var(--ease);
}
.search__toggle:hover { color: var(--fg-1); border-color: var(--accent); background: var(--hover-wash); }

/* Without JS the toggle cannot open anything, so it is not shown at all — the
   search lives on /products/, where the field is open and the form is a plain
   GET. A control that does nothing is worse than no control. */
.no-js .search--nav { display: none; }

.search__box { position: relative; }

.search__form {
  display: flex;
  align-items: center;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-1);
  transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
}
/* The input has no border of its own, so the wrapper carries the focus state —
   copper edge plus a soft ring, visible against both card grounds. */
.search__form:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgb(var(--accent-rgb) / 0.18); }

.search__input {
  flex: 1;
  min-width: 0;
  background: transparent;
  border: 0;
  color: var(--white);
  font-family: var(--sans);
  font-size: var(--fs-small);
  line-height: 1.4;
  padding: 0.72rem 0.25rem 0.72rem 0.95rem;
}
.search__input::placeholder { color: var(--faint); opacity: 1; }
.search__input:focus { outline: none; }
/* The native clear button renders as a pale blob on a near-black field. */
.search__input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }

.search__go, .search__close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 40px;
  height: 40px;
  background: none;
  border: 0;
  border-radius: var(--radius-1);
  color: var(--faint);
  cursor: pointer;
  transition: color 0.3s var(--ease);
}
.search__go:hover, .search__close:hover { color: var(--white); }

/* -- the dropdown ---------------------------------------------------------- */
/* The one place on the site that gets a shadow: it floats over content rather
   than sitting in the band rhythm, so a hairline alone would not detach it. */
.search__panel {
  position: absolute;
  z-index: 60;
  top: calc(100% + 8px);
  left: 0;
  right: 0;
  max-height: min(66vh, 34rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-1);
  box-shadow: 0 18px 44px rgb(var(--shadow-rgb) / 0.55);
}

.search__opt {
  display: grid;
  gap: 0.15rem;
  padding: 0.7rem 1rem;
  min-height: 44px;
  align-content: center;
  border-bottom: 1px solid var(--line-soft);
}
.search__opt:last-child { border-bottom: 0; }
.search__opt:hover, .search__opt.is-active { background: var(--hover-wash); }
/* The keyboard cursor needs a mark of its own. Sharing the hover wash alone
   means that with the pointer resting anywhere over the list, two rows read as
   selected and only one of them is what Enter will open. */
.search__opt.is-active { box-shadow: inset 3px 0 0 var(--accent); }
.search__opt .card__code { font-size: 0.6875rem; letter-spacing: 0.14em; }
.search__opt-name { color: var(--white); font-size: var(--fs-small); line-height: 1.35; }
/* A category row carries its blurb here, which runs to three lines on a phone
   and makes one row twice the height of its neighbours. */
.search__opt-meta {
  line-height: 1.35;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}

.search__empty {
  padding: 0.9rem 1rem;
  font-size: var(--fs-small);
  color: var(--faint);
}

/* -- header placement ------------------------------------------------------ */

@media (min-width: 1025px) {
  /* Absolute against .header__inner, which is already position:relative — but
     that element is also the .wrap, so its padding box runs to the viewport
     edge. right/width both have to pay the gutter back or the field and its
     dropdown hang off the right of the screen. */
  .search--nav .search__box {
    position: absolute;
    z-index: 3;
    top: 50%;
    right: var(--gutter);
    transform: translateY(-50%);
    /* 14rem, not 13: the reserve is for the logo, which is the one thing the
       overlay does not hide, and the 72px lockup is 205px wide (2.85:1). */
    width: min(30rem, calc(100% - 14rem - var(--gutter) * 2));
    visibility: hidden;
    opacity: 0;
    pointer-events: none;
  }
  .search--nav[data-open='true'] .search__box { visibility: visible; opacity: 1; pointer-events: auto; }
  /* The row underneath steps aside instead of being half-covered. visibility,
     not opacity alone, so nothing under the field stays tabbable. */
  .header__inner[data-search-open='true'] .nav,
  .header__inner[data-search-open='true'] .lang,
  .header__inner[data-search-open='true'] .header__phone,
  .header__inner[data-search-open='true'] .header__cta,
  .header__inner[data-search-open='true'] .search__toggle { visibility: hidden; opacity: 0; }
  .search--nav .search__go { display: none; }
}

/* Scoped to the desktop range on purpose: below it, .nav owns a transform
   transition for the burger panel that this would otherwise replace.

   `visibility` is given 0s, never a duration. Interpolated, its computed value
   is still `hidden` for the first frame of a hidden -> visible transition, and
   a hidden element cannot take focus — which is exactly the frame in which the
   script puts the caret in the field. So it flips instantly on the way in and
   is delayed to the end of the fade on the way out; opacity carries the motion
   in both directions. The toggle is deliberately absent: it keeps its own
   colour transition, and snapping a 40px icon costs nothing. */
@media (min-width: 1025px) and (prefers-reduced-motion: no-preference) {
  .search--nav .search__box,
  .header__inner[data-search-open='true'] .nav,
  .header__inner[data-search-open='true'] .lang,
  .header__inner[data-search-open='true'] .header__phone,
  .header__inner[data-search-open='true'] .header__cta {
    transition: opacity 0.28s var(--ease), visibility 0s linear 0.28s;
  }
  .search--nav[data-open='true'] .search__box,
  .header__inner .nav, .header__inner .lang,
  .header__inner .header__phone, .header__inner .header__cta {
    transition: opacity 0.28s var(--ease), visibility 0s;
  }
}

/* -- burger-panel placement ------------------------------------------------ */
/* Below 1024px the script moves the whole component to the top of the panel:
   there is no room for a second magnifier beside the burger, and a buyer who
   opened the menu is already looking for a way in. */
@media (max-width: 1024px) {
  .search--nav { display: block; width: 100%; margin-bottom: 0.5rem; }
  .search--nav .search__toggle, .search--nav .search__close { display: none; }
  /* In the burger panel the results are not a floating layer — they push the
     nav links down, inside a panel that scrolls. */
  .search--nav .search__panel {
    position: static;
    margin-top: 0.5rem;
    max-height: none;
    box-shadow: none;
  }
  /* Thumb-sized: 44px controls and a 16px field, which also stops iOS from
     zooming the viewport on focus. */
  .search__go, .search__close { width: 44px; height: 44px; }
  .search__input { font-size: 1rem; padding-block: 0.85rem; }
  .search__opt { padding: 0.85rem 1rem; min-height: 48px; }
}

/* -- /products/ placement -------------------------------------------------- */

.search--page { position: relative; display: block; max-width: 34rem; margin-top: 2.25rem; }
.search--page .search__form { background: var(--ground); }
.band--surface .search--page .search__form { background: var(--surface); }

/* The intro block on /products/ owns the field, and it has to out-rank the card
   grid that follows it. Both carry data-reveal, and an element mid-opacity
   transition paints as though it were positioned at z-index 0 — so while the
   two were fading in, the cards (later in the document) covered an open
   dropdown. An explicit z-index on the block settles it for the whole fade
   instead of racing the animation. */
.products-intro { position: relative; z-index: 1; }

/* -- results without JavaScript -------------------------------------------- */
/* server.js renders the matches into the page for `GET /products/?q=`. With
   JavaScript running, the dropdown above already answers and this block would
   be the same list twice, so it is hidden by default and revealed only by the
   `.no-js` class the shell sets and the inline head script removes.

   It sits in the flow rather than floating: nothing here can be dismissed with
   Escape or a click outside, so a shadowed overlay would be a panel the reader
   has no way to close. */
.search__ssr { display: none; }
.no-js .search__ssr { display: block; margin-top: 1.5rem; }

.search__ssr-head {
  margin-bottom: 0.75rem;
  font-size: var(--fs-small);
  color: var(--muted);
}
.search__ssr-list {
  border: 1px solid var(--line);
  border-radius: var(--radius-1);
  background: var(--surface);
  overflow: hidden;
}
.search__ssr .search__empty {
  padding: 0.9rem 0;
}

/* ==========================================================================
   footer
   ========================================================================== */

.footer { background: var(--surface); border-top: 1px solid var(--line-soft); padding-block: var(--section-y) 3rem; }
/* Below 1024 the footer is a stack of five blocks rather than a row of four,
   so it is already long; --section-y above the first one is air the page
   cannot afford — this is the widest, tallest thing on a phone and it sits
   under a hairline that already separates it. --section-y-tight is the token
   for a band that wants less. */
@media (max-width: 1023px) {
  .footer { padding-block: var(--section-y-tight) var(--space-5); }
}
.footer__grid { display: grid; gap: 2.5rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); }
/* auto-fit lands on 3 columns at tablet width and orphans the 4th block on
   every page. Two even columns read as intentional. */
@media (min-width: 600px) and (max-width: 1023px) {
  .footer__grid { grid-template-columns: repeat(2, 1fr); }
}
/* Four equal columns were a coincidence of auto-fit, not a decision, and the
   two end columns are the ones carrying weight: the brand lockup on the left
   and the address block on the right. The two link lists between them need
   only the width of a category name. */
@media (min-width: 1024px) {
  .footer__grid { grid-template-columns: 1.4fr 1fr 1fr 1.3fr; }
}
.footer .footer__h {
  font-family: var(--font-structure);
  font-size: var(--type-eyebrow-size);
  font-weight: var(--type-eyebrow-weight);
  letter-spacing: var(--type-eyebrow-track);
  text-transform: uppercase;
  color: var(--copper);
  margin-bottom: 1.25rem;
}
.footer a {
  display: inline-flex;
  align-items: center;
  /* Only the three contact rows carry a mark; on the two link columns the gap
     applies to nothing. inline-flex rather than inline-block so an icon and
     its text share a baseline box instead of the icon sitting on the text
     baseline and pushing the line height. */
  gap: 0.55rem;
  /* 17px tall links are an uncomfortable target; pad to a usable height
     without changing the visual rhythm of the column. */
  padding-block: 0.35rem;
  font-size: var(--fs-small);
  color: var(--muted);
  transition: color 0.3s var(--ease);
}
.footer a:hover { color: var(--white); }
/* The mark is subordinate to the address it labels — it distinguishes a row,
   it does not announce one. It lifts to copper only with the link. */
.footer a .icon { color: var(--fg-3); flex-shrink: 0; transition: color 0.3s var(--ease); }
.footer a:hover .icon { color: var(--accent); }

/* --------------------------------------------------------------------------
   the chat channels — WhatsApp, and the WeChat line that is not a link
   --------------------------------------------------------------------------

   `.channel` is the shared row: a mark, then the channel's name and its number
   on one baseline. It is defined once and worn by both an <a> (WhatsApp) and a
   <span> (WeChat), so the two rows are identical by construction rather than by
   two sets of numbers that agree today.

   WeChat cannot be a link — there is no deep link a website may rely on, and
   inventing one would be a channel that goes nowhere. So the row has to look
   like what it is: text to copy.

   `.channel--flat` drops it to --fg-3, which is the same move the footer's
   address block makes for the same reason ("reference text, not links") — the
   four rows above it are --fg-2, the link colour, and a row that carried the
   link colour with no link under it would be worse than no row at all. 5.28:1
   on --bg-1 and 5.84:1 on --bg-0, so quieter is not less legible. No hover, and
   a text cursor, because copying is the whole interaction. */
.channel { display: inline-flex; align-items: center; gap: 0.55rem; }
.channel .icon { flex-shrink: 0; }
.channel--flat { color: var(--fg-3); cursor: text; }
.channel--flat .icon { color: var(--fg-3); }

/* In the footer the flat row has to sit on the same rhythm as the four links
   above it, and those get their height from `.footer a`'s padding — which a
   <span> never sees. */
.footer .channel--flat {
  padding-block: 0.35rem;
  font-size: var(--fs-small);
}

/* The contact list, and why it does not use the plain `.stack-sm` rhythm the
   two link columns beside it use.

   It went from five rows to seven when WhatsApp and WeChat arrived, and at
   `.stack-sm`'s --space-2 that is 43px of pitch under 14px of text — three
   times the type size, for a block a reader takes in at once rather than a menu
   they scan down. Products and Company ARE menus and keep the wider step; this
   one drops to --space-1, which still leaves 15px of clear air between two
   lines (4px of margin on top of the 11px `.footer a` already pads with) and
   turns seven separate lines back into one address card.

   It also gives the column back 24px, which the home page needed: /zh/ at 1440
   sat at 5192px against the pageHeight probe's 5200px budget BEFORE this round
   — 8px of slack — and two footer rows cost 81px.

   It is the same step at every width, and the phone loses nothing by it: the
   TAP TARGET is the link's own box — 14px of text plus the 0.35rem `.footer a`
   pads with, 28px — and the stack margin is dead space BETWEEN targets, not
   part of one. Widening it back below 1024 would have bought air and called it
   accessibility. */
.footer__channels > * + * { margin-top: var(--space-1); }

/* Address and hours: reference text, not links. Set at --faint under a
   hairline so the eye reads the four clickable lines first and finds the
   where and the when underneath, without a second heading competing with the
   copper one at the top of the column. */
.footer__reach {
  /* --space-3 either side of the hairline, not 1.25rem: the list above it is
     on a tighter step since the two chat channels joined it, and a separator
     that keeps the clearance of the looser list starts shouting over the block
     it is separating. 16/16 against the list's own 4px step is still the
     largest gap in the column, which is what a rule between two kinds of
     content should be. */
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--line-soft);
  font-size: var(--fs-small);
  color: var(--faint);
  max-width: 26ch;
}
.footer__reach address { font-style: normal; line-height: 1.6; }
.footer__reach p { margin-top: 0.75rem; }
/* --------------------------------------------------------------------------
   the compliance line — on all 94 pages
   --------------------------------------------------------------------------

   Three seals, the eight marks the products are tested to, and the FCC grantee
   code, between the footer's columns and its copyright.

   It is a REGISTER, not a band: a hairline above it, a hairline below it (the
   copyright's own), the seals leading and the two clauses following at caption
   size. The seals are the only pictures in the footer, so they carry the block
   on their own and nothing else here needs a heading — the visually-hidden h2
   in the markup names it for a screen reader and takes no space.

   Colour: --fg-3 on the footer's --bg-1 is 5.28:1, which clears AA for body
   text at any size. The certification string is the longest line in the footer
   and the least important thing to read first; it must not be the loudest.
   Only the two things a buyer copies — the marks and the code — lift to
   --fg-1.

   Layout: a single flex row that wraps. At 1024 and up the seals sit left and
   the two clauses run to the right of them on one line; below that the seals
   take their own line and the clauses stack under them, which is the only
   shape that survives 390px with a 2.8:1 mark like BSCI in it. */
.footer__compliance {
  /* One step tighter than the chrome pass shipped (space-5/space-4): the zh
     home at 1440 closed 8px over the 5,200px page budget with the strip's 56px
     of air above it. Two steps down buys 16px and the hairline still reads. */
  margin-top: var(--space-4);
  padding-top: var(--space-3);
  border-top: 1px solid var(--line-soft);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-4) clamp(var(--space-4), 3vw, var(--space-6));
}
/* 40px here, against the register's 48 on /quality/: the footer's row is one
   line among five and the page's is the subject of its own band. */
.footer__compliance .seal img { height: 40px; }
.footer__certs, .footer__fcc {
  font-size: var(--fs-small);
  color: var(--fg-3);
  line-height: 1.5;
}
/* No max-width. The string is 68 characters and there are ~690px left on the
   row at 1440 once the seals and the code have taken theirs, so it sets on one
   line; capping it at a measure only forced a break that left a trailing
   middot hanging at the end of the first line. Below 1024 the block is a
   column and the paragraph wraps against the page gutter like any other. */
.footer__certs { min-width: 0; }
/* The label keeps the caption's own size and family and is separated from the
   list by nothing but the source's own space and a colour step — the same way
   every other emphasis on this site is made. A second copper label down here
   would out-shout the four above it. */
.footer__certs-list, .footer__fcc-code { color: var(--fg-1); }
/* Lining tabular figures: 2ATW9 is an identifier, and an identifier a buyer
   retypes into a federal search box is set on the figure grid. */
.footer__fcc-code { font-variant-numeric: lining-nums tabular-nums; letter-spacing: 0.04em; }
/* The code is the shortest line and the one that gets checked; it may not wrap
   away from its own label. */
.footer__fcc { white-space: nowrap; }
/* Below 1024 the row cannot hold the seals and both clauses, so it becomes a
   stack — and the stack is tightened, because the phone is where this block is
   most expensive. It costs 288px at 390 laid out the desktop way, on a home
   page that was already at 7.270px against a 7.500px budget; the numbers below
   bring it to ~150 and the trims further down pay for the rest. Nothing here
   is under the probe's floor: the marks stay at 40px. */
@media (max-width: 1023px) {
  .footer__compliance {
    align-items: flex-start;
    flex-direction: column;
    gap: var(--space-3);
  }
}
/* On a phone the name goes UNDER its mark. Side by side, the three pairs are
   ~420px wide and wrap onto two 40px rows; stacked, each pair is as wide as
   the wider of its two halves — 275px for all three — so the row holds one
   line and 96px becomes 62. */
@media (max-width: 599px) {
  .seal-row { gap: var(--space-3) var(--space-4); align-items: flex-start; }
  .seal { flex-direction: column; align-items: flex-start; gap: var(--space-2); }
}

/* The copyright follows the compliance line as the next row of the same block,
   not as a new one — 3.5rem above it was the distance from the four columns,
   and the compliance line is now what it follows. */
.footer__base {
  margin-top: var(--space-4);
  padding-top: var(--space-4);
  border-top: 1px solid var(--line-soft);
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  justify-content: space-between;
  font-size: var(--fs-small);
  color: var(--faint);
}
.footer__logo { color: var(--white); }
/* 80px, up from 64: 40px of letterform, and the one place on the site where
   the mark is the subject rather than a wayfinding aid. 80px is 228px wide,
   which is why the brand column is given an explicit share above — under the
   old auto-fit it measured 214px at exactly 1024 and the lockup would have
   been letterboxed. max-width is the belt to that braces: an SVG with a
   viewBox scales down rather than overflowing. */
.footer__logo svg { height: 80px; width: auto; max-width: 100%; }

/* ==========================================================================
   forms
   ========================================================================== */

.field {
  display: grid;
  gap: 0.5rem;
  /* Rows sized to content, with the control pinned to its own row: without
     this the grid stretches to match the tallest sibling in the row (Phone,
     which carries a .hint) and the input absorbs the extra 14px. */
  grid-template-rows: auto auto 1fr;
  align-content: start;
}
.field input, .field select { align-self: start; }
.field label { font-size: var(--fs-small); color: var(--muted); }
.field .opt { color: var(--faint); }

.field input, .field select, .field textarea {
  width: 100%;
  background: var(--ground);
  border: 1px solid var(--line);
  border-radius: var(--radius-1);
  color: var(--white);
  font-family: var(--sans);
  font-size: var(--fs-body);
  padding: 0.85rem 1rem;
  transition: border-color 0.3s var(--ease);
}
.band--surface .field input,
.band--surface .field select,
.band--surface .field textarea { background: var(--surface); }
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--copper); outline-offset: 1px; }
.field textarea { min-height: 9rem; resize: vertical; }
.field .hint { font-size: 0.75rem; color: var(--faint); }

/* The form is at most TWO columns, and the cap is what closes it.
   --------------------------------------------------------------------------
   Both forms on this site are an even number of one-cell fields followed by
   full-width blocks: the home's enquiry is 4 + 3, `/contact/`'s is 8 + 2. An
   even count divides by 1 and by 2, so at one or two columns the last row of
   singles is always full and every `.span-2` after it closes its own row by
   definition.

   THREE columns is the count that breaks both. 4 singles over 3 tracks leaves
   two cells empty beside the fourth field; 8 leaves one. The floor was 16rem,
   so a third track appeared as soon as the form's column reached
   3 x 256 + 2 x 20 = 808px — which the home's enquiry column does at about
   1520px of viewport and above, exactly where nobody was looking. The
   `emptyCells` probe now runs at 1520 and 1680 for that reason.

   The cap is written as the track floor rather than as a media query because
   the thing that decides the count is the FORM's width and not the window's:
   the same `.form-grid` is a full-width block below 900px, a 1.15fr column on
   the home above it, and half of a `.split` on `/contact/`. A media query would
   have to guess all three. `max(16rem, 50% - 0.625rem)` says the same thing in
   the only unit that is true everywhere — half the container, less half the
   20px gap — so a third track never fits, and below 33.25rem of container the
   16rem term still wins and the grid still collapses to one column exactly
   where it did before. */
.form-grid {
  display: grid;
  gap: 1.25rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, max(16rem, 50% - 0.625rem)), 1fr));
}
.form-grid .span-2 { grid-column: 1 / -1; }

/* Honeypot. Off the canvas rather than display:none: a bot that reads computed
   style skips hidden inputs, and this one only works if it gets filled. It is
   out of flow, so it claims no grid cell; aria-hidden and tabindex="-1" in the
   markup keep it away from anyone real. */
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

/* The only piece of runtime state on the site. A hairline and a line of text —
   a coloured alert panel would be the loudest object on a page whose whole
   discipline is restraint, and role="status" announces it regardless. */
.form-status {
  margin: 1rem 0 0;
  padding-left: 0.875rem;
  border-left: 1px solid var(--line);
  font-size: var(--fs-small);
  line-height: 1.55;
  color: var(--faint);
}
.form-status[data-tone='warn'] { border-left-color: var(--danger); color: var(--fg-2); }

/* The line under the button, sized to match the status above it so the two
   read as one footnote rather than two competing voices. */
.form-hint { margin: 0.75rem 0 0; font-size: var(--fs-small); line-height: 1.55; color: var(--faint); }

/* Every other link on the site is a whole object — a nav item, a card, a table
   cell — so the global `a { color: inherit }` is enough to make it obvious.
   This one sits inside a sentence, where inheriting the sentence's colour
   makes it invisible. Copper is the accent the rest of the site already uses
   for "you can act on this". */
.link { color: var(--accent); }
.link:hover { color: var(--accent-hover); text-decoration: underline; text-underline-offset: 0.2em; }

/* ==========================================================================
   the home page — .hpage
   ==========================================================================

   THIS WAS src/styles/home.css, a second design system inside the first. It
   carried its own --section-y (76px against the site's 96), its own --fs-h2,
   and its own .band--light / .grid--5 / .grid--3-close / .row-3 / .chips under
   the same class names as the ones above — which meant the home page and every
   other page disagreed about what those names meant, silently, decided by
   load order.

   What came across and what did not:

   · Its --section-y and --fs-h2 overrides are GONE. There is one band rhythm
     and one h2 step now; the h2 ceiling the whole site moved to (48px) is the
     one this file argued for, so nothing here needed to change to keep it.
   · Its .band--light colour block is GONE. The band is a token swap now and
     re-points --fg-*/--bg-* as well as the aliases, so the seven literal
     overrides it carried would only have fought it.
   · Its .grid--5, .grid--3-close, .row-3 and .chips ARE here, scoped to
     .hpage — because they are genuinely different layouts from the ones above,
     not duplicates: the home's five-up spans its first card at tablet width,
     its three-close is a contact sheet of six renders rather than a card grid,
     and its row-3 is a fixed three-column set. Scoped, they reproduce exactly
     what the home rendered when home.css was the last sheet to load, and no
     other page can be reached by them.
   · Everything .hp-* is unchanged apart from reading tokens.
   ========================================================================== */

/* Five product lines. Five items tile completely in 5, 3 (with the first
   spanning two), 2 (same) or 1 column — and in nothing else. */
.hpage .grid--5 { grid-template-columns: 1fr; }
@media (min-width: 560px) {
  .hpage .grid--5 { grid-template-columns: repeat(2, 1fr); }
  .hpage .grid--5 > :first-child { grid-column: span 2; }
  .hpage .grid--5 > :nth-child(-n + 3),
  .hpage .grid--5 > :nth-child(n + 4) { grid-column: auto; }
  .hpage .grid--5 > :first-child { grid-column: span 2; }
}
@media (min-width: 900px) {
  .hpage .grid--5 { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1200px) {
  .hpage .grid--5 { grid-template-columns: repeat(5, 1fr); }
  .hpage .grid--5 > :first-child { grid-column: auto; }
}

/* A tight three-across for images that read as one composition rather than as
   separate cards — 6 items, so 3 x 2 and 2 x 3 are both complete. */
.hpage .grid--3-close {
  grid-template-columns: repeat(2, 1fr);
  gap: clamp(0.5rem, 0.9vw, 0.85rem);
}
/* Three from 560 rather than from 900: from there up the band stacks and the
   sheet gets the whole column, where two 386px tiles read as two product
   photographs rather than as a contact sheet — and cost 400px of page. */
@media (min-width: 560px) {
  .hpage .grid--3-close { grid-template-columns: repeat(3, 1fr); }
  .hpage .grid--3-close > :last-child:nth-child(3n + 1),
  .hpage .grid--3-close > :last-child:nth-child(3n + 2) { grid-column: auto; }
}

/* Exactly three, at every width: a fixed set gets a fixed count. */
.hpage .row-3 {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: clamp(var(--space-3), 3vw, var(--space-5));
  align-items: end;
}
.hpage .row-3 > * { flex: none; }

/* max-width still matters for a 9:1 mark like Blaupunkt at 1024 — in the GRID,
   where a mark has a cell to overflow. The marquee undoes it; see the note
   beside the marquee rules. */
.hpage .logo-strip img { max-width: 100%; }

/* A register of marks. The container is the system's .chips; the pill is this
   page's, because elsewhere a pill is a .chip element and here it is an <li>. */
.hpage .chips li {
  padding: 0.4rem 0.85rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  font-family: var(--font-structure);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.08em;
  color: var(--fg-2);
  white-space: nowrap;
}

/* The heading of a section is a statement, not a paragraph: cap the measure so
   it breaks where a headline should break rather than running the full 1600.
   26ch rather than the ramp's 24 — these headings are sentences. */
.hp-h2 { max-width: 26ch; }
:lang(zh) .hp-h2 { max-width: 20em; }

/* ==========================================================================
   1 + 2 — the hero, and the proof that anchors its base
   ==========================================================================

   ONE STAGE, TWO COMPOSITIONS, ONE SWITCH. `data-hero-variant` on the
   <section> in home() selects between them and nothing else changes: the
   markup, the copy, the render, the mobile layout and the proof rail are
   shared, and each variant is four declarations on the figure.

     A — THE RENDER BLEEDS.
         It runs the full height of the stage — cut by the header above and by
         the proof's hairline below — and past the right edge of the viewport.
         No frame, no radius, no plate. The headline crosses onto its sweep by
         a few dozen pixels, which is where the depth comes from: one plane in
         front of another, not a shadow under a card. A left-edge mask
         dissolves the render into the ground so the crossing has no seam to
         land on.

     B — THE OBJECT IS MONUMENTAL, AND IT HAS ITS OWN ROOM.
         Two vertical plates pushed together: copy on --bg-0 to the left, and
         a --bg-1 field owning the entire right half, top edge to bottom edge,
         with the product filling ~90% of the stage height inside it. Nothing
         overlaps. The depth is architectural rather than photographic.

   WHY THE FIGURE IS OUT OF FLOW FROM 900px UP. Both compositions need the
   render to reach the section's own top edge, above where the copy starts, and
   its own bottom edge, below where the buttons end. A grid item cannot do
   that; an absolutely positioned one, whose containing block is the stage's
   PADDING box, reaches all four edges by writing `inset-block: 0` once, for
   both variants, and leaves the copy free to be centred in the content box.

   Below 900px there is no composition to make: the figure is a grid row
   between the lead and the buttons, at the render's own 4:5, full width. The
   reader sees the object before being asked to act on it.
   ========================================================================== */

.hp-hero {
  /* No band padding of its own: the stage owns its vertical rhythm, because
     the render has to reach the edges the padding would otherwise hold it off.
     The copy clears the fixed header with padding of its own. */
  padding-block: 0;
  position: relative;
  /* Variant A bleeds the render past the right edge ON PURPOSE. `clip` and not
     `hidden`: `overflow-x: hidden` forces the other axis to `auto`, which turns
     the hero into a scroll container and breaks position:sticky above it.
     `hidden` is written first anyway, as the fallback for a browser with no
     `overflow: clip` (Safari < 16, Firefox < 81): every browser that has `clip`
     takes the later declaration and nothing changes for it, and the ones that
     do not now clip the bleed instead of giving the whole page a horizontal
     scrollbar. The cost the sentence above names is not actually paid here:
     there is no `position: sticky` anywhere on this site — the header is
     `fixed`, which a descendant's scroll container cannot reach. */
  overflow-x: hidden;
  overflow-x: clip;
}

.hp-hero__stage {
  /* The page gutter, resolved against the stage's OWN width. The stage is
     full-bleed, so it cannot use .wrap; this reproduces exactly what .wrap
     computes (max-width --max, centred, --gutter inside) without a second
     max-width to keep in sync — below 1600px the max() picks the gutter. */
  --edge: max(var(--gutter), calc((100% - var(--max)) / 2 + var(--gutter)));
  position: relative;
  display: grid;
  grid-template-areas: 'copy' 'figure' 'cta';
  row-gap: clamp(var(--space-4), 3.5vw, var(--space-5));
  padding-block: 0 clamp(var(--space-5), 4vw, var(--space-6));
}

.hp-hero__copy { grid-area: copy; padding-inline: var(--edge); }
.hp-hero__cta { grid-area: cta; padding-inline: var(--edge); }
/* On a phone the copy is the first thing under the bar and needs the clearance
   the section no longer carries. */
.hp-hero__copy { padding-block-start: calc(var(--header-h) + var(--space-4)); }

.hp-hero__figure { grid-area: figure; margin: 0; position: relative; }

/* ---- the crossfade ------------------------------------------------------
   THREE RENDERS, ONE SLOT, AND THE STATIC ONE IS THE BASE STATE.

   Read the rules outside `@media (prefers-reduced-motion: no-preference)`
   first: they are the hero that shipped. Frame 1 is opaque, frames 2 and 3 are
   opacity 0 with NO transition, and the indicators are three dots that jump-cut
   rather than fade. `prefers-reduced-motion: reduce`, a browser that has never
   heard of the query, and a reader with JavaScript off all land there, and none
   of them needs a rule of its own. This is the same construction the logo
   marquee uses further down, and for the same reason.

   The one-cell grid is what makes it free. All three <img> are placed in row 1
   / column 1, so the box is sized once — by the first image's own width/height
   attributes and `aspect-ratio` — and the other two lay out inside it. Nothing
   is absolutely positioned, nothing is inserted after paint, and the only
   property that ever animates is `opacity`, which is a compositor property.
   Zero CLS by construction rather than by measurement.

   29:36 is the render's own ratio — 928x1152 reduced, and the encoded size of
   every rung of the ladder for all three frames. The old single picture carried
   `4 / 5` here, which is 0.8000 against the render's 0.8056 and cost 0.7% of
   the height to a `cover` crop; on a hero whose contract is that the product is
   whole, 0.7% of a crop is still a crop, and writing the real ratio costs
   nothing.

   --bg-1 is only what shows for the few hundred milliseconds before the WebP
   decodes, and it is on the FRAME BOX rather than on each picture: three
   stacked placeholders would have painted the same slab three times over. */
.hp-hero__frames {
  display: grid;
  position: relative;
  aspect-ratio: 29 / 36;
  background: var(--bg-1);
}
/* Every child in the same cell. `> *` and not `.hp-hero__frame` so the dot row
   lands there too, and so a frame that loses its class still stacks instead of
   opening a second row under the picture. */
.hp-hero__frames > * { grid-area: 1 / 1; }

.hp-hero__frame { opacity: 0; }
.hp-hero__frame.is-current { opacity: 1; }
/* The belt to the JS braces: with no script nothing ever gets `.is-current`,
   and the first frame has to be the one on screen. It carries the class from
   the build too — this is what holds if that ever stops being true. */
.no-js .hp-hero__frame:first-child { opacity: 1; }

/* The dots ride in the same one-cell grid as the frames, pinned to the bottom
   centre of the frame box — so they belong to the photograph and travel with it
   when the box's width follows the stage height on desktop. They are the last
   child, so they paint over the render without needing a z-index race. */
.hp-hero__dots {
  z-index: 2;
  align-self: end;
  justify-self: center;
  display: flex;
  gap: var(--space-2);
  /* The row is a hit area, not a decoration: 44px of height so a thumb reaches
     the dots without reaching the render behind them. */
  align-items: center;
  min-height: 44px;
}
.no-js .hp-hero__dots { display: none; }

/* 10px of painted dot inside a 44px control. The control is what a finger and a
   focus ring get; the dot is what the eye gets. Anything smaller than 44 fails
   the touch-target audit, and anything bigger than 10 stops being discreet. */
.hp-hero__dot {
  appearance: none;
  -webkit-appearance: none;
  padding: 0;
  border: 0;
  background: none;
  width: 44px;
  height: 44px;
  cursor: pointer;
  display: grid;
  place-items: center;
  /* The 44px boxes would otherwise sit 44px apart and read as three separate
     controls rather than one indicator row. */
  margin-inline: calc(var(--space-2) * -1);
}
.hp-hero__dot::before {
  content: '';
  width: 10px;
  height: 10px;
  border-radius: 50%;
  /* Inactive is a RING, active is a FILLED disc — the state is carried by fill
     and not by a shade, so it survives a display that crushes the difference
     between two greys. Measured on the darkest pixel either dot can land on
     (--bg-0, 10,10,10): the ring at --fg-3 is 4.9:1 against it and the filled
     --fg-1 dot is 19.0:1, so the ACTIVE-to-INACTIVE ratio the WCAG 1.4.11
     non-text requirement asks for is 19.0/4.9 = 3.9:1. Over the render's own
     sweep, which never goes below 9 or above 40 in the frame band, both numbers
     only improve. */
  box-shadow: inset 0 0 0 1.5px var(--fg-3);
}
.hp-hero__dot.is-current::before {
  background: var(--fg-1);
  box-shadow: none;
}
/* No focus rule of its own: the site-wide :focus-visible ring is the accent and
   it lands on the whole 44px control, which is exactly the affordance a
   keyboard reader needs to see — the hit area, not the 10px dot inside it. */

@media (prefers-reduced-motion: no-preference) {
  /* THE FADE, AND NOTHING ELSE. 1s each way; site.js holds a frame for ~7s
     before handing over, so the loop is six seconds of stillness and one of
     movement rather than a slideshow that never settles. `opacity` only — a
     transform here would be a slide, and a hero that slides is the thing this
     composition is not. */
  .hp-hero__frame { transition: opacity 1s var(--ease); }
  .hp-hero__dot::before { transition: background-color 0.4s var(--ease), box-shadow 0.4s var(--ease); }
}

/* The display step, not the h1 one. The old hero put the headline in a narrow
   column beside a card, where 88px cost it three lines; this one gives it the
   width to be the largest thing on the page, which is what --type-display is
   for. .h-hero already carries the step — nothing is redeclared here. */
.hp-hero__title { max-width: var(--measure-h1); }
.hp-hero__lead { margin-top: var(--space-4); max-width: 42ch; }

/* On a phone the product is the full width of the screen. No radius and no
   plate — the render's own studio sweep measures 9–15 at the corners against
   --bg-0's 10, so it meets the page ground instead of sitting on a card cut out
   of it. `cover` into a box of the picture's own ratio crops nothing, so the
   `object-position` that used to nudge the crop off the dust cover has nothing
   left to nudge and is not written. */
.hp-hero__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

@media (min-width: 900px) {
  .hp-hero__stage {
    /* 70vh keeps the whole hero — stage plus the proof rail under it — inside
       the first screen at 1440x900, which is the width and height the owner
       reviews at: 630 of stage and 211 of proof lands the last row of retail
       marks at 841. The floor stops a short laptop from crushing the render;
       the ceiling stops a tall monitor from turning it into a poster. */
    min-height: clamp(32rem, 70vh, 44rem);
    grid-template-areas: 'copy' 'cta';
    align-content: center;
    row-gap: clamp(var(--space-5), 3vw, var(--space-6));
    /* Top padding clears the header for the CONTENT; the figure is positioned
       against the padding box and so still reaches y=0. */
    padding-block: var(--header-h) clamp(var(--space-5), 3vw, var(--space-6));
  }
  .hp-hero__copy { padding-block-start: 0; }

  /* Out of flow, and behind the copy.

     THE RENDER STARTS AT THE HEADER'S LOWER EDGE, NOT AT THE SECTION'S.
     It was written the other way first — the render running to y=0 and passing
     under the fixed bar, with a scrim over the top 96px to keep the nav legible.
     The scrim was not the problem. `.header` is `position: fixed` with a
     `backdrop-filter` and a 0.4s transition, so in a FULL-PAGE screenshot Chrome
     composites its layer from a stale tile and paints the closed nav and the
     open search panel on top of each other. With nothing behind the bar that is
     invisible. With a photograph behind it, `contrastOnImage` — which measures
     REAL PIXELS, and is right to — started reading the nav against a ghost, and
     read it differently on every run: "Catalogue" at 2.98:1 in one pass and
     clean copper in the next, the phone number at 3.16 / 3.31 / 3.95 across
     three. A gate that reports a different number each run is not a gate, and
     no depth of scrim fixes a stale layer.

     Starting the render at --header-h costs the composition almost nothing: the
     render's own top rows measure 10–25 against --bg-0's 10, so in variant A the
     join is invisible, and in variant B the --bg-1 plate still runs to the
     section's top edge because the plate is the FIGURE'S background and only the
     <img> inside it is inset. What it buys is a hero the navigation never has to
     be legible over — which is where §6 arrives from the other direction: if a
     headline needs a scrim deep enough to bury the product, the headline is in
     the wrong place. Here it was the navigation. */
  .hp-hero__figure {
    position: absolute;
    inset-block: 0;
    z-index: 0;
  }
  .hp-hero__copy,
  .hp-hero__cta { position: relative; z-index: 1; }

  /* THE FRAME BOX IS THE RENDER'S OWN SHAPE, AND THAT IS WHAT KILLS THE CROP.

     `aspect-ratio: 29 / 36` is 928x1152 reduced — the exact encoded size of all
     three renders, at every rung of the ladder. A `cover` fit into a box of the
     image's own ratio crops NOTHING, at any viewport, in any of the three
     frames: the guarantee is geometric rather than tuned, so it cannot drift
     when a render is replaced with another 29:36 one.

     What was here before was the opposite bargain — a box 892x534 at 1440
     (1.67:1) with a 0.806:1 render fitted `cover` into it, which shows 48% of
     the picture's height and throws the rest away. That crop is the defect this
     round exists to remove.

     The box is HEIGHT-DRIVEN and its width follows: `height` fills the stage
     below the bar, `aspect-ratio` derives the width, `margin-inline: auto`
     centres it in whatever the figure spans. So the composition holds at every
     width without a second breakpoint — narrow the viewport and the render
     narrows with it instead of being cropped harder.

     The picture is inset from the top of its figure by exactly the bar; the
     figure itself is not, so variant B's plate keeps the section's top edge. */
  .hp-hero__frames {
    height: calc(100% - var(--header-h));
    margin-block-start: var(--header-h);
    margin-inline: auto;
    aspect-ratio: 29 / 36;
    /* The --bg-1 placeholder is a MOBILE affordance and has to go here, because
       on desktop it is a plate: it paints 24,23,22 behind a picture whose edges
       are being faded to nothing, so the dissolve below would have revealed the
       plate instead of the ground and the rectangle would have survived it.
       Measured before this line existed: the frame's left edge stepped 10 → 22
       with the mask already on. The old stylesheet said `background:
       transparent` here for the same reason, on the picture rather than on the
       box. */
    background: transparent;
  }
  .hp-hero__img {
    height: 100%;
    width: 100%;
    aspect-ratio: auto;
    background: transparent;
  }

  /* THE FRAME DISSOLVES AT ITS EDGES INSTEAD OF ENDING AT THEM.

     A box that is exactly the picture's ratio crops nothing, which is the whole
     point — but it also draws a rectangle, because the renders' backdrops are a
     vignette and their edge pixels are not the page ground. Measured across the
     three, at the frame's own left and right edges: 24→41 against --bg-0's 10.
     That step is a plate, and §6 of docs/design-foundations.md does not want one
     under this hero.

     So the edges are faded rather than drawn, and the fade distances are the
     product's own clearance rather than a number that looked right. The
     narrowest margin any of the three leaves, measured by horizontal gradient
     over the 928px renders (a column counts as product when four or more rows
     jump by 6 levels across it):

        tt61-black    x 53..853   left 5.7%  right 8.0%   top 12.8%  bottom 21.5%
        micro-ms106a  x 52..871   left 5.6%  right 6.0%   top 30.0%  bottom 25.5%
        spk-b333      x 87..870   left 9.4%  right 6.1%   top 24.3%  bottom 19.3%

     4% and 9% therefore clear the tightest of them (5.6% and 12.8%) with room
     to spare, in every frame, at every width — the fade is a fraction of the
     box, so it scales with it. NOTHING OF ANY PRODUCT IS EVER TOUCHED BY IT.

     Two layers intersected, not one: a single gradient can fade one axis. Where
     `mask-composite` is not understood the layers are `add`ed, the union is
     opaque almost everywhere, and the result is the hard edge this replaces —
     a browser that cannot dissolve the frame gets the frame, which is the same
     bargain the marquee's mask makes.

     ON THE PICTURES AND NOT ON THE BOX. `.hp-hero__frames` carries the dot row
     as well, and a mask on the parent fades the controls with the photograph. */
  .hp-hero__frame {
    --hero-dissolve:
      linear-gradient(to right, transparent, rgb(var(--scrim-rgb)) 4%, rgb(var(--scrim-rgb)) 96%, transparent),
      linear-gradient(to bottom, transparent, rgb(var(--scrim-rgb)) 9%, rgb(var(--scrim-rgb)) 91%, transparent);
    -webkit-mask-image: var(--hero-dissolve);
    mask-image: var(--hero-dissolve);
    -webkit-mask-composite: source-in;
    mask-composite: intersect;
  }

  /* ---- A · the render bleeds ------------------------------------------- */
  /* Where the render starts. One number, and everything else in the variant is
     derived from it, so the composition cannot come apart at one width: at 1024
     the copy needs more of the stage than it does at 1440, and moving this is
     the whole adjustment. */
  /* Where the render starts, as one number the rest of the variant derives
     from. 46 and not 40: at 40% the render is another 86px of turntable, and it
     costs the lead a third line at every width — a two-line lead is the brief,
     and 46% also crops 5 points less off the top and bottom of the product.
     The bleed survives it: the plinth's right edge lands 40px past 1440. */
  [data-hero-variant='a'] .hp-hero__stage {
    --hero-split: 46%;
  }

  /* THE BLEED IS GONE, AND THE MASK WENT WITH IT.

     Both existed to serve one crop: the render ran 8vw past the right edge so
     the plinth would be cut by the viewport, and a left-edge mask dissolved the
     seam the headline crossed. Neither survives a hero whose contract is that
     the product is WHOLE in every frame — there is no bleed left to read as
     intent, and a mask fading a render into a ground it already matches is a
     gradient doing nothing. `right: 0` and no mask.

     The figure is now a room the frame box is centred in, and the box is
     narrower than the room at every width the site has (measured at 900, 1024,
     1440 and 1920, with the stage at both its 32rem floor and its 44rem
     ceiling: widest box 490px into a 558px room). The h1 therefore no longer
     crosses onto the render — `--measure-h1` stops it around 763px at 1440 and
     the product's own left edge lands near 857. The crossing was the old
     composition's depth; this one's is the fade, and a headline lying across a
     render that changes underneath it every seven seconds would have been a
     different contrast problem three times over. */
  [data-hero-variant='a'] .hp-hero__figure {
    left: var(--hero-split);
    right: 0;
  }

  /* THE COLUMN STOPS AT THE RENDER. THE HEADLINE DOES NOT.
     The lead and the buttons end a space-5 short of the render's edge — a grey
     paragraph running onto a lit plinth is the contrast failure this site
     measures for, and a button half on a photograph is a control with two
     backgrounds. The h1 is let past by a negative end margin, and then
     --measure-h1 (14ch) is the only thing that stops it. That crossing is the
     composition's depth: one plane in front of another, with no shadow and no
     scrim under it, over sweep that measures 9–35 against a #FAFAFA headline. */
  /* The copy block keeps the full stage width; what stops SHORT of the render
     is the lead and the button row, each capped by hand, and what does not is
     the headline. Two earlier attempts are worth naming because both look
     right until a second width is checked:

       · padding-inline-end on the column, with a negative end margin on the h1
         to escape it — a percentage in a child's margin resolves against the
         PARENT'S CONTENT box, not the stage, so the same expression bought 288px
         of escape at 1440 and 200px at 1024 and the headline silently lost its
         two-line setting on the narrower one.
       · a fixed overhang in rem — `text-wrap: balance` (site-wide on headings)
         flips the setting from three lines to two the moment the box clears the
         longest line, so a value tuned at 1440 broke at 1280.

     Bounding the two blocks that must not touch the photograph, and letting
     --measure-h1 bound the one that must, is the version that holds at every
     width because each rule says what it means. */
  /* The lead stops a space-6 clear of the render: a body grey is the one thing
     on this page that would actually fail the contrast probe over a lit plinth,
     and text needs air from a photograph even when it clears it. */
  [data-hero-variant='a'] .hp-hero__lead {
    max-width: calc(var(--hero-split) - var(--space-6));
  }
  /* The buttons only have to not cross — they are opaque objects, and the ghost
     one would otherwise show the photograph through its own fill. A space-6 of
     clearance here wrapped the row onto two lines at 1024, which is a worse
     fault than a tight one. */
  [data-hero-variant='a'] .hp-hero__cta .btn-row {
    max-width: calc(var(--hero-split) - var(--space-2));
  }
  /* Nothing else: --measure-h1 (14ch — 739px at the 88px display step) is what
     stops the headline, and the crossing is whatever is left over. A grey
     paragraph running onto a lit plinth is the contrast failure this site
     measures for; a headline at 88px in --fg-1 over a sweep that measures 9–35
     is not. */

  /* ---- B · the object, monumental, in its own field --------------------- */
  /* The right half entire — top edge to bottom edge, to the viewport's right
     edge. --bg-1 is the field; the render's own sweep measures 17,21,22 against
     that plane's 24,23,22, so the two meet instead of nearly meeting. */
  [data-hero-variant='b'] .hp-hero__figure {
    left: 50%;
    right: 0;
    background: var(--bg-1);
  }
  /* B had an `object-position: 50% 34%` here and it is gone with the crop it
     steered: the frame box is now the picture's own ratio, so there is no
     overflow for a position to choose from. B still works — the plate is the
     figure's background and the centred frame box sits inside it — and it is
     still not the variant on the page. */
  /* Nothing crosses the seam in B: the copy stops before the plate starts. */
  [data-hero-variant='b'] .hp-hero__copy,
  [data-hero-variant='b'] .hp-hero__cta { padding-inline-end: max(var(--edge), 54%); }
}


/* The proof is the hero's plinth. A hairline, then the label, the markets and
   the ten marks — so the claim above it and the evidence for it are read as one
   object, and in variant A the hairline is also what cuts the render off. */
.hp-proof {
  position: relative;
  z-index: 1;
  border-top: 1px solid var(--line);
  padding-block: clamp(var(--space-4), 2.4vw, var(--space-5)) clamp(1.75rem, 2.8vw, 2.25rem);
}
.hp-proof__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2) var(--space-5);
  margin-bottom: clamp(1.25rem, 2.2vw, 1.75rem);
}
/* Not caption grey and not copper: ten retail names are the strongest thing
   this company owns, and the label that introduces them is a heading in
   everything but tag. */
/* G3 — "MADE FOR" is an eyebrow in everything but its class name: it names the
   region a reader is entering, which §2 has always allowed the copper. It was
   --fg-1, i.e. the weight of a heading, above ten partner marks that are the
   heading. */
.hp-proof__label {
  font-family: var(--font-structure);
  font-size: 0.8125rem;
  font-weight: var(--type-eyebrow-weight);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--accent);
}
:lang(zh) .hp-proof__label { letter-spacing: 0.12em; }
.hp-markets {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.35rem 0.8rem;
  font-size: var(--type-caption-size);
  color: var(--fg-2);
}
/* G3 — "SHIPPED TO", the second half of the same head line, takes the same
   copper as "MADE FOR" beside it. Two labels on one baseline in two different
   colours read as two different kinds of thing. */
.hp-markets__label {
  font-family: var(--font-structure);
  font-size: var(--type-eyebrow-size);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
}
.hp-markets > span:last-child { display: flex; flex-wrap: wrap; gap: 0.35rem 0.8rem; }
.hp-markets > span:last-child > span { white-space: nowrap; }
/* The separator trails its region and never the last one — as a ::before it
   became the first thing on a wrapped line. */
/* G3 — the separator is copper at full strength. It is punctuation, so it is
   held to the NON-TEXT floor of Lc 45 and clears it at 53.7 over the well and
   57.9 over the ground. It reads as the mark that groups the markets rather
   than as ten items with a faint speck between them. */
.hp-markets > span:last-child > span:not(:last-child)::after {
  content: '·';
  margin-left: 0.8rem;
  color: var(--accent);
}
@media (max-width: 700px) {
  .hp-proof__head { justify-content: flex-start; gap: 0.4rem 1.25rem; }
}

/* --------------------------------------------------------------------------
   3 — five lines
   -------------------------------------------------------------------------- */

.hp-lines__grid { margin-top: clamp(1.75rem, 3vw, var(--space-5)); }
.hp-line .card__body { gap: 0.4rem; padding: 1.25rem 1.25rem 1.4rem; }
.hp-line .cat-card__count { order: -1; }

/* Between 640 and 1199 the first card spans two tracks so no row is left short.
   A double-width card in the vertical layout would be a 2:1 banner over a strip
   of text, so it becomes a horizontal one instead. */
@media (min-width: 560px) and (max-width: 1199.98px) {
  .hp-lines__grid > :first-child { flex-direction: row; align-items: stretch; }
  .hp-lines__grid > :first-child .card__media { flex: 0 0 44%; }
  .hp-lines__grid > :first-child .card__media img {
    width: 100%;
    height: 100%;
    aspect-ratio: auto;
    object-fit: cover;
  }
  .hp-lines__grid > :first-child .card__body { justify-content: center; }
}

/* Below 560px every card goes horizontal: ~160px per row instead of ~360px
   stacked. Five vertical cards were 1,811px of a phone page. */
@media (max-width: 559.98px) {
  .hp-lines__grid > * { flex-direction: row; align-items: stretch; }
  .hp-lines__grid > * .card__media { flex: 0 0 38%; }
  .hp-lines__grid > * .card__media img {
    width: 100%;
    height: 100%;
    aspect-ratio: auto;
    object-fit: cover;
  }
  .hp-lines__grid > * .card__body {
    justify-content: center;
    padding: 0.9rem var(--space-3);
    gap: 0.3rem;
  }
  /* The three sample codes are the first thing to give on a narrow row. */
  .hp-lines__grid > * .cat-card__codes { display: none; }
}

/* --------------------------------------------------------------------------
   4 — the catalogue, on the light band
   -------------------------------------------------------------------------- */

.hp-cat__grid { display: grid; gap: clamp(var(--space-5), 5vw, 4.5rem); align-items: center; }
@media (min-width: 900px) {
  .hp-cat__grid { grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); }
}
.hp-cat__lead { margin-top: 1.25rem; max-width: 34ch; }
/* --space-4, not --space-5: the row holds two controls now (the Contact us pill
   and the demoted download), so it is a block under the lead rather than a
   single button floated away from it. One body line of clearance is the site's
   own block gap. */
.hp-cat__cta { margin-top: var(--space-4); }

/* The six renders were six pictures floating on the paper. They are a CONTACT
   SHEET now: one well, one plane down. --bg-1 inside .band--light resolves to
   --bg-light-1 (#E1DDD6) through the band's token swap — the light ramp's own
   "a card on the band", the step docs/design-foundations.md defined and nothing
   used. It recedes rather than rises, which is how paper behaves, and the tiles
   keep their neutral --bg-plate so each render still meets its own backdrop and
   now reads as mounted on the sheet instead of adrift on it. */
.hp-cat__sheet {
  background: var(--bg-1);
  border-radius: var(--radius-2);
  padding: clamp(var(--space-3), 1.6vw, var(--space-4));
}

.hp-tile { margin: 0; }
/* 1:1, like the hero: a square takes only the empty studio margin off a 4:5
   render, and six portrait plates two rows deep would have made this band
   taller than the rest of the page put together. */
.hp-tile__img {
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: 10px;
  background: var(--bg-plate);
}
.hp-tile figcaption {
  margin-top: var(--space-2);
  font-family: var(--font-structure);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.08em;
  text-align: center;
  color: var(--fg-2);
}
.hp-cat__note {
  margin-top: var(--space-3);
  font-family: var(--font-structure);
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--fg-2);
}

/* --------------------------------------------------------------------------
   5 — the factory
   -------------------------------------------------------------------------- */

.hp-factory__grid { display: grid; gap: clamp(var(--space-5), 4vw, var(--space-7)); align-items: center; }
@media (min-width: 900px) {
  .hp-factory__grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}
.hp-factory__lead { margin-top: var(--space-4); }
.hp-factory__cta { margin-top: var(--space-5); }
.hp-factory__figure figcaption { text-align: left; }

/* 50,000 / 40,000 / 30,000 a month is the single strongest fact the company
   owns, and a hairline over a dark band gave it the weight of a footnote. It is
   a RAISED WELL now — --bg-2 on the band's --bg-0, which is the ramp's own
   "one plane up" (ΔL 0.120 from the ground, two full steps) — so the three
   figures read as the page's principal statement of scale rather than as the
   last line of the factory paragraph. No shadow: the plane and the hairline are
   the depth, exactly as §2 has it.

   Every pair inside is measured on bg-2: --fg-1 14.63:1, --fg-2 7.04:1,
   --fg-3 4.50:1, --accent 6.82:1. */
.hp-capacity {
  margin-top: clamp(var(--space-5), 3.5vw, var(--space-6));
  background: var(--bg-2);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-2);
  padding: clamp(var(--space-4), 2.4vw, var(--space-5));
}
.hp-capacity__label { margin-bottom: var(--space-4); }
.hp-capacity__grid { grid-template-columns: 1fr; }
@media (min-width: 640px) {
  .hp-capacity__grid { grid-template-columns: repeat(3, 1fr); }
}
/* 50,000 / 40,000 / 30,000 a month is the strongest fact the company owns and
   it was set in a 12px spec table. It is the same figure style as .stat__n —
   the structural face, tabular and lining — so the two rows of numbers on this
   site are one voice. */
.hp-cap__n {
  font-family: var(--font-structure);
  font-weight: 600;
  font-size: var(--fs-stat);
  letter-spacing: -0.015em;
  line-height: 1;
  font-variant-numeric: lining-nums tabular-nums;
}
/* G3 — the figures are the metal. See the --accent-num block in tokens.css for
   the derivation and for why `color` stays a solid: it is the mean of the fill,
   it is what every audit tool reads, and it is what a renderer without
   background-clip paints. Only the glyph FILL goes transparent. */
.hp-cap__n,
.hp-stats__n,
.stat__n { color: var(--accent-num); }
@supports (background-clip: text) or (-webkit-background-clip: text) {
  .hp-cap__n,
  .hp-stats__n,
  .stat__n {
    background-image: var(--accent-num-grad);
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
  }
}
.hp-cap__unit {
  margin-left: var(--space-2);
  font-family: var(--type-caption-family);
  font-size: var(--type-caption-size);
  font-weight: 400;
  letter-spacing: 0.02em;
  color: var(--fg-3);
  white-space: nowrap;
}
:lang(zh) .hp-cap__unit { margin-left: 0.35rem; }
.hp-cap__label { margin-top: 0.7rem; font-size: var(--type-caption-size); color: var(--fg-2); }

/* The four statistics were a band of their own, then a caption under the
   capacity block. They are the second line of the capacity plate now, under a
   hairline: same well, same fact, smaller voice. --fg-3 measures 4.50:1 on
   bg-2, which is the floor §2 guarantees for it on this plane. */
.hp-stats {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem var(--space-5);
  margin-top: var(--space-4);
  padding-top: var(--space-4);
  border-top: 1px solid var(--line-soft);
  font-size: var(--type-caption-size);
  color: var(--fg-3);
}
.hp-stats li { display: flex; align-items: baseline; gap: 0.45rem; }
/* Colour comes from the G3 figure rule above, with .hp-cap__n. */
.hp-stats__n {
  font-family: var(--font-structure);
  font-weight: 600;
  font-variant-numeric: lining-nums tabular-nums;
}

/* --------------------------------------------------------------------------
   6 — who audited us
   -------------------------------------------------------------------------- */

/* The one band on this page that asks for less air, and it asks with the token
   rather than with a number of its own (§3): trust is a REGISTER — a heading,
   three marks, eight acronyms and a code you can look up — sitting between the
   factory's statement and the enquiry that closes the page. Given the same 96px
   as a statement band it read as a third statement, and it pushed the form
   below the point anyone scrolls to. No .band--tight: that class also draws a
   top hairline, and this band already changes plane, so the seam would be the
   same edge stated twice. */
.hp-trust { padding-block: var(--section-y-tight); }

/* Capped: three 72px marks spread across 1,392px read as three isolated
   pictures. At 52rem they read as one row of evidence. */
.hp-seals { margin-top: clamp(var(--space-5), 3.4vw, var(--space-6)); max-width: 52rem; }
.hp-seal { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); text-align: center; }
/* build-badges.mjs already writes these white-on-transparent. Both dimensions
   are max-constrained so a 2.8:1 mark like BSCI shrinks inside its own cell
   instead of overflowing it on a phone. */
.hp-seal img {
  width: auto;
  height: auto;
  max-height: 72px;
  max-width: 100%;
  opacity: 0.95;
}
@media (max-width: 640px) {
  .hp-seal img { max-height: 48px; }
  .hp-seal { gap: 0.7rem; }
}
.hp-seal__name {
  font-family: var(--font-structure);
  font-size: var(--type-caption-size);
  font-weight: 500;
  letter-spacing: 0.06em;
  color: var(--fg-1);
}

.hp-trust__foot {
  margin-top: clamp(var(--space-5), 3.4vw, var(--space-6));
  border-top: 1px solid var(--line-soft);
  padding-top: clamp(var(--space-4), 2.4vw, var(--space-5));
}
.hp-trust__label { margin-bottom: var(--space-3); }
.hp-trust__line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3) var(--space-5);
  margin-top: clamp(var(--space-4), 2.4vw, 1.75rem);
}
/* The one line on this page a buyer can check against a public database. */
.hp-fcc {
  font-family: var(--font-structure);
  font-size: var(--type-caption-size);
  letter-spacing: 0.06em;
  color: var(--fg-1);
  font-variant-numeric: lining-nums tabular-nums;
}

/* --------------------------------------------------------------------------
   7 — the enquiry
   -------------------------------------------------------------------------- */

/* Centred, not top-aligned: a heading, one line and an address beside a
   seven-field form leaves 400px of empty black under the copy if the two
   columns start together. */
/* The target of every primary button on the home page. Without a scroll-margin
   the browser puts the section's top edge at the viewport's top edge, which is
   UNDER the fixed header — the reader arrives at a band whose heading is behind
   the nav. --header-h is the same token the header is sized from, so the two
   can never drift; --space-4 is a line of body text of clearance below it. */
.hp-enquiry { scroll-margin-top: calc(var(--header-h) + var(--space-4)); }

.hp-enquiry__grid { display: grid; gap: clamp(var(--space-5), 4vw, var(--space-7)); align-items: center; }
@media (min-width: 900px) {
  .hp-enquiry__grid { grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); }
}
.hp-enquiry__lead { margin-top: 1.25rem; max-width: 34ch; }
.hp-enquiry__email { margin-top: 1.75rem; font-size: var(--type-caption-size); }

/* The message box is a prompt, not a document: 7rem holds three lines, and the
   form is the last thing before the footer. /contact/ keeps the long form. */
.hp-enquiry .field textarea { min-height: 7rem; }

/* ==========================================================================
   motion — reveal on scroll, disabled when the user asks for less
   ========================================================================== */

@media (prefers-reduced-motion: no-preference) {
  [data-reveal] {
    opacity: 0;
    transform: translateY(24px);
    transition: opacity 0.8s var(--ease), transform 0.8s var(--ease);
  }
  [data-reveal='left'] { transform: translateX(-40px); }
  [data-reveal='right'] { transform: translateX(40px); }
  [data-reveal='scale'] { transform: scale(0.98); }
  [data-reveal].is-in { opacity: 1; transform: none; }
}

/* Without JS (or with reduced motion) everything is simply visible. */
.no-js [data-reveal] { opacity: 1; transform: none; }

@media print {
  .header, .footer, .brackets { display: none; }
  body { background: var(--paper); color: var(--paper-ink); }
}
