/* ============================================================
   Data Systems Catalogue — visual system v3
   Apple-inspired: quiet neutral canvas, SF-style type ramp, one
   green accent, frosted surfaces, soft depth, restrained motion.
   ============================================================ */

:root {
  /* Four tiers of text, every one of them readable. --faint was #86868b, which
     measures 3.62:1 on white and 3.32:1 on the canvas: below the 4.5:1 that
     normal-size body text has to clear, and it carried real content (filter
     labels, dates, source links, the footer). Darkening it forced --muted down
     with it so the hierarchy still reads as four steps rather than three.
     Ratios against #ffffff / --canvas #f5f5f7:
       --ink    16.1 / 14.8    --ink-2  9.7 / 8.9
       --muted   6.3 /  5.8    --faint  5.3 / 4.9  */
  --ink: #1d1d1f;
  --ink-2: #424245;
  --muted: #5f5f66;
  --faint: #6b6b73;
  --line: rgba(0, 0, 0, 0.08);
  --line-strong: rgba(0, 0, 0, 0.14);
  /* Apple's SF, and only Apple's SF. Copied stop for stop from the reference's
     --font-display / --font-sans / --font-mono, because "the same typeface"
     has to mean the same fallback order too: a stack that ends in Inter and
     Roboto is a different design on every machine that does not have SF.
     Said plainly, because the owner asked for SF site-wide and deserves to
     know what he is getting: SF is Apple's and cannot be served as a webfont
     by us or by anyone. On a Mac, an iPhone or an iPad every word on this site
     is genuine SF. On Windows and Android `system-ui` hands over to Segoe UI
     and Roboto, which is what the reference does too. Loading a lookalike
     would cost every visitor a font download to be slightly wrong.

     The fallbacks are named rather than left to `system-ui`. `system-ui` does
     resolve to Segoe UI on Windows and Roboto on Android, but only where the
     browser implements it and the OS is configured as expected; where it does
     not, the stack fell straight through to generic `sans-serif`, which on
     Windows is Arial and on some Linux desktops is whatever fontconfig picks
     first. Naming Segoe UI, Roboto, Helvetica and Arial in order makes the
     result the same everywhere instead of the same on Apple and a lottery
     elsewhere.

     Text before Display. They are optical sizes of one typeface, and the
     browser takes the first that resolves, so listing Display first asked for
     the cut drawn for headlines to set body copy: tighter spacing and thinner
     strokes at 15px, which is what it is drawn to avoid. Text first, and SF
     itself switches to the Display cut at large sizes. */
  --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
    "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-display: var(--font-sans);
  --mono: ui-monospace, "SF Mono", Menlo, monospace;

  --canvas: #f5f5f7;
  /* The same paper at zero alpha. Fading to the keyword `transparent` means
     fading through rgba(0,0,0,0), which lays a grey fringe along every edge
     where paper meets sky; naming the colour keeps the ramp clean. It is a
     token rather than a literal so it cannot drift from --canvas. */
  --canvas-0: rgba(245, 245, 247, 0);
  --panel: #ffffff;
  --plate: #ffffff;
  /* The bars a card shows while its contents are still being drawn. Two
     weights so a skeleton reads as a card rather than as stripes: the heavier
     one is the primary action, which is the darkest thing on a real card. */
  --skeleton: rgba(15, 23, 42, 0.055);
  --skeleton-strong: rgba(15, 23, 42, 0.1);
  --green: #0d9f6e;
  --green-deep: #087a54;
  --accent-grad: linear-gradient(135deg, #0d9f6e, #0abf85);

  /* ---- Merged from the CommScope Connect design system (Lovable) ----
     That project's palette earns its keep in two places. `--brand-lime` is
     sampled from the Data Systems logo's own leaf — the one colour that is
     genuinely ours — and the electric/teal pair gives dark surfaces the
     command-centre depth the portal has. Both stay decorative: lime on white
     fails text contrast, so the emerald pair above remains the accent that
     carries meaning, and these carry atmosphere. */
  --brand-lime: #b8e76a;
  --brand-lime-deep: #6fa326;
  --electric: oklch(0.61 0.22 259);
  --electric-soft: oklch(0.72 0.16 248);
  --teal: oklch(0.72 0.13 188);
  --mesh-grad:
    radial-gradient(circle at 20% 0%, rgba(20, 184, 166, 0.10), transparent 44%),
    radial-gradient(circle at 80% 100%, rgba(59, 130, 246, 0.12), transparent 50%);
  --deep-grad:
    radial-gradient(ellipse 72% 52% at 50% -10%, rgba(37, 99, 235, 0.30), transparent 60%),
    linear-gradient(180deg, #020714 0%, #060f1e 38%, #0b1626 100%);
  --sweep-grad: linear-gradient(90deg, transparent 0%, rgba(184, 231, 106, 0.55) 50%, transparent 100%);
  --ease-confident: cubic-bezier(0.2, 0.8, 0.2, 1);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 2px 6px rgba(0, 0, 0, 0.05), 0 14px 34px rgba(0, 0, 0, 0.09);
  --shadow-lg: 0 8px 20px rgba(0, 0, 0, 0.08), 0 30px 70px rgba(0, 0, 0, 0.14);
  --r-sm: 12px;
  --r-md: 18px;
  --r-lg: 26px;
  /* Motion tokens.
     `--spring` is the generated spring curve from springs.css (real physics
     sampled into a CSS linear() easing) with the old hand-picked bezier kept as
     the fallback, so every existing `var(--spring)` rule inherits genuine
     overshoot-and-settle and the page still animates if that file is missing.
     Springs drive transforms only — opacity and colour have no momentum, and
     springing them turns a fade into a flicker, so those keep `--ease`. */
  --ease: cubic-bezier(0.25, 0.1, 0.25, 1);
  --spring: var(--spring-snappy, cubic-bezier(0.34, 1.3, 0.5, 1));
  --pad-x: clamp(20px, 5.5vw, 96px);
  /* The stacking order, named. Values are the ones the page already shipped
     with, so nothing visually moves; the point is that the next overlay picks
     a name from this ladder instead of typing 999 and hoping. */
  --z-float: 30;
  --z-header: 40;
  --z-drawer: 50;
  --z-suggest: 60;
  --z-tray: 70;
  --z-toast: 80;
  --z-palette: 90;
  --z-skip: 100;
  --z-dialog: 1000;
  /* The reading and content width. Five rules were already using this token
     and nothing defined it, so every one of them was silently on its own
     fallback and the page had as many content widths as fallbacks. Defining it
     makes the token true; 1180px is the value the majority already fell back
     to, so nothing moves except the outliers. */
  --measure: 1180px;
}

* { box-sizing: border-box; }

/* The `hidden` attribute is only a UA-level `display: none`, so any class rule
   that sets `display` silently defeats it. That is not a cosmetic problem: the
   command palette is a fixed, full-viewport overlay, and while `hidden` was
   being ignored it sat invisibly over the page and swallowed every click on it.
   Anything marked hidden is hidden, whatever else it is styled to be. */
[hidden] { display: none !important; }

html {
  scroll-behavior: smooth;
  scroll-padding-top: 92px;
  -webkit-text-size-adjust: 100%;
  /* `clip`, on both html and body, and neither of those is arbitrary.
     `overflow-x: hidden` on body alone does not do what it reads as. When one
     axis is `hidden` and the other computes to `visible`, the visible axis
     becomes `auto` and the element turns into a scroll container, which stops
     the value propagating to the viewport. The page was therefore still
     draggable sideways: scrollTo(600) left scrollX at 167, with the brand
     marquee as the overhang.
     `hidden` on html would fix the scrolling and break the sticky header, since
     a scroll container is what `position: sticky` resolves against. `clip`
     clips without creating one, so the header still sticks and the page cannot
     be dragged. */
  overflow-x: clip;
}

body {
  margin: 0;
  overflow-x: clip;
  background: var(--canvas);
  color: var(--ink);
  font-family: var(--font-sans);
  line-height: 1.47;
  font-synthesis: none;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  /* The reference's stylistic sets, which it names as one of the two details
     most often dropped in a port. On SF these change the shape of a, g and the
     digits; without them the same family reads slightly wider and softer than
     the original does. */
  font-feature-settings: "ss01", "ss02", "cv02", "cv03", "cv04", "cv11";
}

/* Headings carry --font-display as they do in the reference. It resolves to
   the same stack today; keeping the two names apart is what makes it a
   one-line change if a display face is ever licensed. */
h1, h2, h3, h4, h5, h6 { font-family: var(--font-display); }

/* The stack, stated at the root and on every element that does not inherit it.
   `body` alone is nearly enough, and nearly is the problem. Form controls carry
   a UA font that inheritance does not reach, which is why `font: inherit` is
   below. The root matters for anything resolved outside the body's subtree:
   `::backdrop` behind a modal dialog, and the scrollbar and form widgets some
   engines style from the document element. Naming the family in both places
   costs nothing and removes the class of bug where one surface on the site is
   set in Arial and nobody notices for a month. */
html,
body,
button,
input,
select,
textarea {
  font-family: var(--font-sans);
}

a { color: inherit; text-decoration: none; }
/* `font: inherit` on top of the family above, deliberately: it also carries
   size, weight and line-height down, so a select does not sit at 13.33px
   Arial next to the input beside it. */
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
img { max-width: 100%; }
::selection { background: rgba(13, 159, 110, 0.2); }

:focus-visible {
  outline: 2px solid var(--green);
  outline-offset: 3px;
  border-radius: 6px;
}

h1, h2, h3, h4, p, dl { margin-top: 0; }

/* Sized so the two-clause headline holds two lines rather than three: at 88px
   it grew to 264px tall and pushed the search field under the fold. */
/* Metrics taken from the reference rather than eyeballed. Its note on this was
   blunt and correct: a heading set at the default -0.02em reads generic
   whatever family loads, because the tightness is the identity, not the face.
   The size ramp is kept a little shorter than the reference's 4.8rem ceiling
   so the two-clause headline still holds two lines rather than three; at 88px
   it grew to 264px tall and pushed the search field under the fold. */
h1 {
  margin-bottom: 18px;
  font-size: clamp(38px, 4.8vw, 68px);
  font-weight: 700;
  line-height: 1.02;
  letter-spacing: 0;
}

/* Emphasis is a weight and a colour, never a clipped gradient. A transparent
   text fill is one unsupported property away from an invisible headline, and
   the hero's neon treatment needs a solid glyph underneath it before it can
   glow at all: you cannot light a letter that is a hole. */
h1 em {
  font-style: normal;
  color: #4d7c0f;
}

h2 {
  font-size: clamp(30px, 3.6vw, 52px);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: 0;
}

h3 { letter-spacing: 0; }

.sr-only, .skip-link {
  position: absolute;
  width: 1px !important; height: 1px !important;
  min-width: 0 !important;
  max-width: 1px !important;
  overflow: hidden;
  clip: rect(0 0 0 0);
}

.skip-link:focus {
  z-index: var(--z-skip);
  width: auto; height: auto;
  left: 18px; top: 18px;
  clip: auto;
  padding: 12px 18px;
  border-radius: 999px;
  background: var(--ink);
  color: #fff;
  font-weight: 600;
}

/* ---------- Ambient background ---------- */

.aurora {
  position: fixed;
  inset: 0;
  width: 100vw;
  max-width: 100vw;
  overflow: hidden;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(900px 540px at 85% -10%, rgba(13, 159, 110, 0.07), transparent 60%),
    radial-gradient(760px 480px at 4% 8%, rgba(10, 132, 255, 0.05), transparent 55%),
    var(--canvas);
}

.grid-veil, .grain { display: none; }

/* Atmospheric orbs, revived with the CommScope Connect treatment: two soft
   radials in electric and teal and one in the logo's own lime, drifting on a
   40-second orbit. They sit behind everything at single-digit opacity — depth,
   not decoration — and the global reduced-motion rule parks them. */
.blob {
  position: absolute;
  width: 46vw;
  max-width: 720px;
  aspect-ratio: 1;
  border-radius: 50%;
  filter: blur(90px);
  opacity: 0.5;
  animation: orbit-aurora 40s var(--ease-confident, ease-in-out) infinite;
  will-change: transform;
}

.blob-a {
  top: -18%; right: -8%;
  background: radial-gradient(circle, rgba(59, 130, 246, 0.16), transparent 65%);
}

.blob-b {
  top: 22%; left: -14%;
  background: radial-gradient(circle, rgba(20, 184, 166, 0.13), transparent 65%);
  animation-delay: -13s;
}

.blob-c {
  top: 58%; right: 6%;
  width: 30vw;
  background: radial-gradient(circle, rgba(184, 231, 106, 0.12), transparent 65%);
  animation-delay: -27s;
}

@keyframes orbit-aurora {
  0%, 100% { transform: translate(-6%, -4%) scale(1); }
  50% { transform: translate(5%, 3%) scale(1.08); }
}

/* A note that governs the whole sheet: the live field is one canvas fixed
   behind the entire page, so anything carrying backdrop-filter has its blur
   recomputed on every frame the field draws, everywhere, all the way down.
   That is what made the site feel heavy well below the hero. Blurs are gone
   from every surface the field shows through, and the grounds underneath
   were raised to carry the legibility the blur had been lending them. Two
   remain, on the photo badge and the dialog close button, because a blur a
   few dozen pixels across is not what a compositor chokes on. */

/* ---------- Header ---------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: var(--z-header);
  background: rgba(251, 251, 253, 0.94);
  border-bottom: 1px solid transparent;
  transition: border-color 300ms var(--ease), background 300ms var(--ease);
}

.site-header.is-stuck {
  border-bottom-color: var(--line);
  background: rgba(251, 251, 253, 0.92);
}

.top-strip {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 18px;
  min-height: 34px;
  padding: 5px var(--pad-x);
  border-bottom: 1px solid var(--line);
  color: var(--muted);
  font-size: 13px;
}

.top-strip a { color: var(--green-deep); font-weight: 600; transition: opacity 160ms var(--ease); }
.top-strip a:hover { opacity: 0.65; }

.pulse-dot {
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 0 0 rgba(13, 159, 110, 0.4);
  animation: pulse-confident 2.4s ease-in-out infinite;
}

/* The portal's live-indicator pulse: scale and opacity breathe together, which
   reads as "alive" where the old expanding ring read as "alert". */
@keyframes pulse-confident {
  0%, 100% { opacity: 0.55; transform: scale(1); }
  50% { opacity: 1; transform: scale(1.18); }
}

@keyframes pulse {
  70% { box-shadow: 0 0 0 9px rgba(13, 159, 110, 0); }
  100% { box-shadow: 0 0 0 0 rgba(13, 159, 110, 0); }
}

.nav-shell {
  min-height: 72px;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: clamp(12px, 1.6vw, 32px);
  padding: 10px var(--pad-x);
}

/* No min-width:0 here — the grid column must reserve the full
   wordmark window, otherwise nav links overlap the logo. */
.brand { display: flex; align-items: center; }

/* The mark carries the full identity: no box, no duplicate text.
   datasystems-mark-dark.png is trimmed to its own ink and already
   carries both inks, so it needs no oversized render and no
   clipping window. What used to be here scaled an untrimmed lockup
   to 88px and cropped a 40px window out of the middle of it, which
   is why the D and the final s were shaved off at both edges.
   Ratio 1631:281, so 30px of height is 174px of width; flex-basis
   and min-width pin that against flex and grid shrink, which is
   what stops the nav links painting over the wordmark. */
.brand-mark {
  display: block;
  flex: 0 0 174px;
  width: 174px;
  min-width: 174px;
  height: 30px;
  background: none;
  border: 0;
  box-shadow: none;
}

.brand-mark img {
  display: block;
  width: 174px;
  height: 30px;
}
.brand-text { display: none; }

/* min-width:0 + overflow guard: the links are nowrap, so without these they
   overflow this grid column and paint over the wordmark and the search box. */
.nav-links {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  /* `overflow: hidden` was quietly amputating the nav: adding About pushed
     "Categories" to "ories" and "Contact" to "Cc" on a 1440px screen and
     nobody was told. A nav that cannot fit must shrink, not bleed. */
  overflow: visible;
  flex-wrap: nowrap;
  gap: clamp(2px, 0.6vw, 8px);
  color: var(--ink-2);
  font-size: clamp(12.5px, 0.95vw, 14px);
  font-weight: 500;
}

.nav-links a {
  padding: 8px clamp(6px, 0.7vw, 11px);
  border-radius: 999px;
  white-space: nowrap;
  transition: background 180ms var(--ease), color 180ms var(--ease);
}

.nav-links a:hover { background: rgba(0, 0, 0, 0.05); color: var(--ink); }

.nav-actions { display: flex; align-items: center; gap: 10px; }

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

.header-search { position: relative; width: clamp(180px, 17vw, 300px); }

.search-icon {
  position: absolute;
  left: 14px; top: 50%;
  width: 16px; height: 16px;
  transform: translateY(-50%);
  fill: none;
  stroke: var(--faint);
  stroke-width: 2;
  stroke-linecap: round;
  pointer-events: none;
}

.header-search input {
  width: 100%;
  height: 40px;
  /* Asymmetric because the two ends hold different things. Left is the 16px
     search glyph at 14px in, so 40px clears it. Right holds the ⌘K badge,
     which starts 13px from the edge and is 20-30px wide depending on how the
     label sets — 40px of padding ended 3px short of it, so the placeholder ran
     underneath and the field read "Search a brand or p⌘K". A caret arriving at
     the end of a typed part code hit the same wall. */
  padding: 0 54px 0 40px;
  border: 0;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.05);
  color: var(--ink);
  outline: none;
  transition: background 200ms var(--ease), box-shadow 200ms var(--ease);
}

.header-search input::placeholder { color: var(--faint); }

.header-search input:focus {
  background: #fff;
  box-shadow: 0 0 0 3.5px rgba(13, 159, 110, 0.22), var(--shadow-sm);
}

.header-search input::-webkit-search-cancel-button { -webkit-appearance: none; }

.search-kbd {
  position: absolute;
  right: 13px; top: 50%;
  transform: translateY(-50%);
  min-width: 20px; height: 20px;
  padding: 0 5px;
  display: grid;
  place-items: center;
  border: 1px solid var(--line-strong);
  border-radius: 5px;
  background: none;
  color: var(--faint);
  font-family: inherit;
  font-size: 10px;
  font-weight: 600;
  cursor: pointer;
  transition: color 180ms var(--ease), border-color 180ms var(--ease), background 180ms var(--ease);
}

.search-kbd:hover {
  color: var(--green-deep);
  border-color: rgba(13, 159, 110, 0.4);
  background: rgba(13, 159, 110, 0.08);
}

.search-kbd:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }

/* ---------- Suggestions ---------- */

.suggest {
  position: absolute;
  left: 0; right: 0;
  top: calc(100% + 10px);
  z-index: var(--z-suggest);
  display: none;
  max-height: 60vh;
  overflow: auto;
  padding: 7px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: rgba(255, 255, 255, 0.92);
  box-shadow: var(--shadow-lg);
}

.suggest.is-open { display: block; animation: pop-in 240ms var(--spring); }

@keyframes pop-in {
  from { opacity: 0; transform: translateY(-8px) scale(0.985); }
}

.suggest button {
  width: 100%;
  display: grid;
  grid-template-columns: 44px 1fr;
  gap: 12px;
  align-items: center;
  padding: 8px;
  border: 0;
  border-radius: var(--r-sm);
  background: transparent;
  text-align: left;
  transition: background 130ms var(--ease);
}

.suggest button:hover,
.suggest button.is-active { background: rgba(0, 0, 0, 0.05); }

.suggest .s-thumb {
  width: 44px; height: 44px;
  display: grid;
  place-items: center;
  overflow: hidden;
  border-radius: 10px;
  background: var(--plate);
  border: 1px solid var(--line);
}

.suggest .s-thumb img { width: 100%; height: 100%; }
.suggest .s-thumb.pending { color: var(--faint); font-size: 9px; font-weight: 700; }
.suggest .s-name {
  display: block;
  font-size: 14px;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.suggest .s-meta {
  display: block;
  color: var(--muted);
  font-size: 11px;
  font-family: var(--mono);
}
.suggest .s-empty { margin: 0; padding: 16px; color: var(--muted); font-size: 13px; }

/* ---------- Buttons ---------- */

.button,
.toolbar-actions button,
.filter-header button,
.drawer-tools button,
.mobile-menu,
.dialog-close,
.drawer-header button {
  min-height: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 0 18px;
  border: 0;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.05);
  color: var(--ink);
  font-size: 14px;
  font-weight: 600;
  white-space: nowrap;
  transition: background 180ms var(--ease), transform 180ms var(--spring), box-shadow 180ms var(--ease), color 180ms var(--ease);
}

.button:hover,
.toolbar-actions button:hover,
.filter-header button:hover,
.drawer-tools button:hover { background: rgba(0, 0, 0, 0.09); }

.button:active { transform: scale(0.965); }

.button.lg { min-height: 50px; padding: 0 28px; font-size: 16px; }

.button.primary {
  background: var(--green);
  color: #fff;
  box-shadow: 0 2px 10px rgba(13, 159, 110, 0.28);
  position: relative;
  overflow: hidden;
}

/* Lovable's scan-sweep, repurposed as a one-shot sheen on hover. The lime
   catches the logo leaf without ever carrying text, so contrast is safe. */
.button.primary::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--sweep-grad);
  opacity: 0.35;
  transform: translateX(-120%);
  pointer-events: none;
}

.button.primary:hover::after { animation: btn-sheen 0.7s var(--ease-confident); }

@keyframes btn-sheen {
  from { transform: translateX(-120%); }
  to { transform: translateX(160%); }
}

.button.primary:hover {
  background: var(--green-deep);
  box-shadow: 0 6px 20px rgba(13, 159, 110, 0.36);
  transform: translateY(-1px);
}

.ico { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.button.primary .ico, .whatsapp-float svg { fill: currentColor; stroke: none; }

.count-pill {
  min-width: 21px; height: 21px;
  display: grid;
  place-items: center;
  padding: 0 6px;
  border-radius: 999px;
  background: var(--green);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  transition: transform 300ms var(--spring);
}

.cart.bump .count-pill { transform: scale(1.4); }

.mobile-menu {
  display: none;
  width: 42px;
  padding: 0;
  flex-direction: column;
  gap: 4px;
}

.mobile-menu span {
  width: 16px; height: 1.8px;
  margin: 0;
  border-radius: 2px;
  background: var(--ink);
  transition: transform 240ms var(--ease), opacity 240ms var(--ease);
}

.mobile-menu[aria-expanded="true"] span:nth-child(1) { transform: translateY(5.8px) rotate(45deg); }
.mobile-menu[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.mobile-menu[aria-expanded="true"] span:nth-child(3) { transform: translateY(-5.8px) rotate(-45deg); }

/* ---------- Scroll reveals ---------- */

/* Sections lift into place as they come into view.
 *
 * The hidden state is gated behind `html.js`, set by an inline script in the
 * document head. Without that gate a reader whose JavaScript failed to run
 * would get a page of invisible sections, because the class that reveals them
 * (`.in`) is applied by an IntersectionObserver. Gating it means the hidden
 * state only ever exists in a document that can undo it, and the head script
 * runs before first paint so nothing flashes on the way in.
 */
.reveal {
  opacity: 1;
  transform: none;
  transition:
    opacity 620ms var(--ease-out, var(--ease)),
    transform var(--spring-gentle-duration, 800ms) var(--spring-gentle, var(--ease));
}

html.js .reveal:not(.in) {
  opacity: 0;
  transform: translateY(18px);
}

.reveal.in { opacity: 1; transform: none; }

/* ---------- Hero ---------- */

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 16px;
  color: var(--green-deep);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: none;
}

/* ---------- Hero ----------
   A dark band, then the light catalogue. The signal field is the CommScope
   Connect look rebuilt as static SVG plus layered gradients: their version
   runs a canvas particle loop every frame, which is exactly the main-thread
   work that was taken off this page to get the first product card down to 1.5s
   on a phone. Nothing here executes on the device.
   The band stops at the hero deliberately. Below it sit 9,187 product cards
   that people read part codes off, and those stay on white. */
.hero {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(340px, 430px);
  align-items: center;
  gap: clamp(36px, 5vw, 80px);
  /* Trimmed from 128px: the search field is the primary action and belongs well
     inside the first screen, not 550px down it. */
  padding: clamp(40px, 5vw, 78px) var(--pad-x) clamp(46px, 5vw, 72px);
  border: 0;
  isolation: isolate;
  /* The wash under the field. A lime bloom top right where the headline ends,
     a blue counterweight bottom left so the band is not lit from one side only,
     and a darkening at the foot so the join to the white catalogue is a clean
     line rather than a gradient that peters out. */
  background:
    radial-gradient(120% 90% at 78% 6%, rgba(184, 231, 106, 0.15), transparent 58%),
    radial-gradient(ellipse 78% 52% at 50% -12%, rgba(37, 99, 235, 0.34), transparent 62%),
    radial-gradient(100% 80% at 8% 100%, rgba(27, 95, 217, 0.16), transparent 60%),
    linear-gradient(180deg, #030916 0%, #060f1e 46%, #0a1524 100%);
}

/* The Signal Field: a measuring grid, orthogonal routes that turn at right
   angles the way tracks are laid out on a board, and the nodes those routes
   terminate in. Three depths, each drifting on its own path at its own speed.
   One layer moving is invisible; three moving against each other is what reads
   as a live field.
   This replaced a starfield. Scattered stars said "space"; a grid and routes
   say "infrastructure", which is what the desk actually sells.
   An earlier pass drifted a single layer 1.4% over 120 seconds, which is about
   0.02% per second: animated on paper, motionless to a reader.
   All of it is transform and opacity, so it is composited and costs no main
   thread, and the global reduced-motion rule parks every bit of it. */
.field {
  position: absolute;
  inset: -10% -6%;
  z-index: -1;
  pointer-events: none;
  background-repeat: repeat;
  will-change: transform, opacity;
}

/* Masked away before it reaches the headline, so the copy never sits on ruled
   paper. It drifts exactly one tile, which is why the loop has no seam. */
/* Periods are the whole argument here, and the first port got them wrong.
   The reference this came from drifts at 90s, 64s and 48s, which measured at
   6 to 9 pixels of travel over three seconds on layers sitting at 16% and 50%
   opacity. That is animated on paper and motionless to a reader, which is
   exactly the failure the starfield had before it, and shipping it again was a
   straight regression. The periods below are roughly a third of that, with the
   amplitudes roughly doubled.
   The grid is the exception and stays slow on purpose: it is a measuring grid,
   and a ruler that visibly slides is a distraction rather than a background. */
.field-grid {
  background-image: url("/catalog/assets/signal-grid.svg");
  background-size: 128px 128px;
  opacity: 0.16;
  -webkit-mask-image: radial-gradient(120% 100% at 50% 0%, #000 35%, transparent 78%);
  mask-image: radial-gradient(120% 100% at 50% 0%, #000 35%, transparent 78%);
  animation: field-drift-grid 34s linear infinite;
}


/* The grid travels exactly one tile, so the loop closes on itself invisibly. */
@keyframes field-drift-grid {
  to { transform: translate3d(-128px, -128px, 0); }
}

/* The same grid tile again, at whisper strength under the top of the catalogue,
   so the dark hero and the light product grid read as one page rather than two.
   The reference set this at 0.5 and let it run the full height of the section.
   Measured, that put the section eyebrow at 3.95:1 and the supporting line at
   3.70:1, both below AA. At 0.16, faded out inside 200px, the grid still reads
   as texture and every piece of text over it clears 4.5:1. The catalogue is
   where people read part codes off the screen, and that comes first. */
.paper-field {
  position: relative;
  isolation: isolate;
}

.paper-field::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image: url("/catalog/assets/signal-grid.svg");
  background-size: 128px 128px;
  opacity: 0.16;
  -webkit-mask-image: linear-gradient(180deg, #000, transparent 200px);
  mask-image: linear-gradient(180deg, #000, transparent 200px);
}

.paper-field > * { position: relative; z-index: 1; }

/* ---------- A second dark band ----------
   The hero is not the only place the field belongs. A band the reader meets
   again further down is what makes the page feel like one designed thing rather
   than a dark header stuck on a white document.
   It is rationed deliberately: bands carry brand and identity, never product
   cards. Everything a buyer reads a part code off stays on the light ground,
   which is what the accessibility pass depends on. */
.ink-band {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  color: #ffffff;
  background:
    radial-gradient(120% 90% at 82% 0%, rgba(184, 231, 106, 0.13), transparent 58%),
    radial-gradient(100% 80% at 10% 100%, rgba(27, 95, 217, 0.15), transparent 60%),
    linear-gradient(180deg, #060f1e 0%, #050b16 52%, #040a12 100%);
  background-color: #050b16;
}

.ink-band > * { position: relative; z-index: 1; }
.ink-band h2 { color: #ffffff; }
.ink-band .sub, .ink-band p { color: rgba(233, 240, 250, 0.72); }
.ink-band .section-heading .sub { color: rgba(240, 246, 255, 0.95); }
.ink-band .eyebrow { color: var(--brand-lime); }

/* The numbered band eyebrow, to the reference's own measurements.
 *
 * Its answer on this was specific enough to copy rather than interpret: mono,
 * 11px, uppercase, tracking 0.28em, lime, and a middot rather than a period
 * between the number and the word. The numbering runs sequentially down the
 * dark bands, which is the part that carries information — a reader arriving
 * mid-page can see where they are in a sequence rather than in a heap of
 * sections. The catalogue takes no number because it is the light band, and on
 * light ground the reference's eyebrow is blue and unnumbered.
 *
 * The tracking differs between the two on purpose, 0.28em on dark against
 * 0.22em on light: lime on black needs more air than blue on white. */
.eyebrow.band-number {
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--brand-lime);
  font-feature-settings: "ss01", "ss02";
}

/* And on a light ground it is blue, unnumbered, and set tighter. Only the
   catalogue qualifies today; the rule is here so a new light band inherits the
   right treatment instead of borrowing the dark one. */
.section:not(.ink-band) .eyebrow {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--green-deep);
}

/* Text on the band, scoped to .hero-copy rather than .hero. The hero also holds
   the white featured panel, and everything inside that card has to keep its
   dark-on-white colours: lightening .panel-note by scoping to .hero put grey
   text on a white background at roughly 1.4:1. */
.hero-copy .eyebrow { color: var(--brand-lime); }
.hero-copy h1 { color: #ffffff; }

/* Starry neon, third recipe, and this one is the classic because the first
 * two were both fighting the same hidden problem.
 *
 * Attempt one hung a 44px drop-shadow on the whole h1: a lit cloud behind the
 * text, not glowing text. Attempt two moved tight drop-shadows onto the two
 * spans, which should have worked and did not, and the reason is the fills.
 * Both lines were gradient-clipped, so the glyphs were pale, semi-transparent
 * shapes; drop-shadow rasterises the glyph and blurs THAT, and a pale
 * letterform blurred at 26px is fog whatever the radius stack says. The owner
 * called it dark, big and not luminous, and all three were exactly right.
 *
 * Real neon is not a gradient. It is a solid tube, near-white at the core,
 * with a saturated halo around it. In CSS that is solid opaque text plus a
 * text-shadow stack in the tube colour at 4, 12, 26 and 52px. text-shadow was
 * unusable while the fills were clipped, because a shadow paints through a
 * transparent fill; on solid glyphs it is both the right tool and the cheap
 * one, and the halo compounds instead of washing out.
 *
 * So the gradient clip is retired, the letters go solid and luminous, and the
 * colour lives in the halo: line one neon green as the owner asked, line two
 * indigo to answer it, each breathing on its own phase. */
/* Each tube gets its own compositor layer.

   The breathing animates text-shadow, which is a paint property: every frame
   the browser redraws the glyphs and four blurred halos out to a 52px radius.
   Without a layer of its own that repaint dirties the region it sits in, and
   what sits behind this headline is the animated field canvas, so one pulsing
   word was forcing the whole field to recomposite with it. On its own layer the
   repaint stays inside the layer and the canvas below is left alone.

   `will-change: opacity` rather than `transform`: it promotes the element
   without introducing a transform, which on text would resample the glyphs
   through the compositor and soften them. This is the one place on the site
   worth spending a layer on, because it is the one place where something
   repaints continuously on top of something else that is already animating. */
.hero-copy h1 .metal {
  color: #f4ffd8;
  -webkit-text-fill-color: #f4ffd8;
  background: none;
  text-shadow:
    0 0 4px rgba(217, 249, 157, 0.95),
    0 0 12px #a3e635,
    0 0 26px #84cc16,
    0 0 52px rgba(132, 204, 22, 0.55);
  position: relative;
}

/* The bright half of the pulse, as a second copy of the same word.

   The glow used to animate text-shadow directly, which looks right and is the
   single most expensive thing on the page. A shadow is part of an element's ink
   overflow, so changing it changes the overflow rectangle, and Chrome relaid
   out the document on every frame of a 3.6s loop that never ends. Measured:
   with every CSS animation disabled the page did 0 layouts in five seconds;
   with the headline alone removed, 1; untouched, 126. One headline was the
   whole of it, and the field canvas behind it was recompositing to keep up.

   So neither shadow stack animates now. Both are static, one on the element and
   the brighter one on this ::after, and the pulse is an opacity crossfade
   between them. Opacity is a compositor property: no layout, no repaint, no
   work on the main thread at all. The effect is identical. */
.hero-copy h1 .metal::after {
  content: attr(data-glow);
  position: absolute;
  inset: 0;
  color: inherit;
  -webkit-text-fill-color: inherit;
  text-shadow:
    0 0 6px rgba(236, 252, 203, 1),
    0 0 16px #bef264,
    0 0 34px #a3e635,
    0 0 66px rgba(163, 230, 53, 0.6);
  opacity: 0;
  animation: glow-pulse 3.6s ease-in-out infinite;
  pointer-events: none;
}

/* The indigo line runs to two rows, and the halo of the upper row lands on top
   of the halo of the lower one. Two 52px washes stacked in the leading read as
   one pale cloud with letters somewhere inside it, which is the fog this whole
   pass exists to remove. So the second tube is deliberately not a recolour of
   the first: the outer radius comes in, the deep end goes to a saturated
   violet rather than a lighter periwinkle, and the core stays tight. Same
   brightness at the letter, far less of it in the gap between rows. */
.hero-copy h1 em {
  color: #eef1ff;
  -webkit-text-fill-color: #eef1ff;
  background: none;
  text-shadow:
    0 0 4px rgba(199, 210, 254, 0.98),
    0 0 11px #818cf8,
    0 0 22px #4f46e5,
    0 0 38px rgba(79, 70, 229, 0.45);
  position: relative;
}

/* Same crossfade as the green tube, one phase behind it. */
.hero-copy h1 em::after {
  content: attr(data-glow);
  position: absolute;
  inset: 0;
  color: inherit;
  -webkit-text-fill-color: inherit;
  font-style: inherit;
  text-shadow:
    0 0 6px rgba(224, 231, 255, 1),
    0 0 15px #a5b4fc,
    0 0 29px #6366f1,
    0 0 48px rgba(99, 102, 241, 0.5);
  opacity: 0;
  animation: glow-pulse 3.6s ease-in-out infinite;
  animation-delay: 1.2s;
  pointer-events: none;
}



.hero-copy .eyebrow {
  text-shadow: 0 0 6px rgba(184, 231, 106, 0.9), 0 0 16px rgba(184, 231, 106, 0.5);
  position: relative;
}

/* The eyebrow is small enough that its shadow is cheap, but it was relaying out
   the page for the same reason, so it crossfades too. */
.hero-copy .eyebrow::after {
  content: attr(data-glow);
  position: absolute;
  inset: 0;
  color: inherit;
  text-shadow: 0 0 8px rgba(214, 242, 154, 1), 0 0 24px rgba(184, 231, 106, 0.75);
  opacity: 0;
  animation: glow-pulse 4.2s ease-in-out infinite;
  animation-delay: 0.7s;
  pointer-events: none;
}

/* The promotion the comment above promised and the stylesheet never made.

   The note beside `.hero-copy h1 .metal` says the glow is held on its own
   layer with `will-change: opacity`, and gives the reason: this is the one
   place where something repaints continuously on top of something else that is
   already animating. The reasoning was right and the declaration was never
   written, so all three of these were repainting four stacked text-shadows —
   the widest of them 66px of blur, on 72px type — on the main thread, every
   frame of a loop that never ends, directly over the field.

   Here rather than on each rule because there are three of them and they are
   one decision. Measured at the hero with the canvas mask already gone: 95th
   percentile frame time drops from 50.0ms to 33.4ms, which is the difference
   between a visible hitch every second or so and none. */
.hero-copy h1 .metal::after,
.hero-copy h1 em::after,
.hero-copy .eyebrow::after {
  will-change: opacity;
}

@keyframes glow-pulse {
  0%, 100% { opacity: 0; }
  50% { opacity: 1; }
}


@media (prefers-reduced-motion: reduce) {
  .hero-copy h1 .metal::after,
  .hero-copy h1 em::after,
  .hero-copy .eyebrow::after { animation: none; opacity: 0.5; }
}

.hero-copy .lede { color: rgba(233, 240, 250, 0.88); }

.hero-copy h1 em {
  /* Its own line, always. The two clauses carry two different tube colours, so
     where the line falls is not typographic taste — it decides whether the
     reader sees green over indigo or a smear through the middle of a word. The
     old headline happened to break in the right place because its first clause
     was long; the new one is short enough that "behind" climbed onto line one
     and took half the treatment with it.
     Line break only. Colour and glow are set once, in the neon block above. */
  display: block;
}

/* Chips read as glass on the dark band instead of as white pills.

   Scoped to .hero, and that is the whole fix. This rule was written when the
   hero went dark and it has never once applied: an identical `.hero-chips
   button` selector sits about thirty lines further down setting a 70% white
   pill, and on equal specificity the later rule wins. So the intent was
   recorded here, the opposite shipped, and the comment above quietly described
   something nobody had ever seen. One class of extra specificity settles it.

   Glass rather than white is also the right answer now for a reason that did
   not exist when it was written: every other surface on a dark band became
   dark glass in the depth-of-field pass, so a row of bright white pills under
   a neon headline was the last piece of light UI left on a near-black page. */
.hero .hero-chips button {
  border-color: rgba(255, 255, 255, 0.22);
  background: rgba(255, 255, 255, 0.08);
  color: rgba(240, 246, 255, 0.92);
}

.hero .hero-chips button:hover {
  border-color: rgba(184, 231, 106, 0.75);
  background: rgba(184, 231, 106, 0.16);
  color: #ffffff;
}

.hero-chips button:hover {
  border-color: rgba(184, 231, 106, 0.75);
  background: rgba(184, 231, 106, 0.16);
  color: #ffffff;
}

.hero .hero-secondary .button.ghost {
  border: 1px solid rgba(255, 255, 255, 0.28);
  background: rgba(255, 255, 255, 0.1);
  color: #ffffff;
}

.hero .hero-secondary .button.ghost:hover { background: rgba(255, 255, 255, 0.18); }

.lede, .sub { color: var(--ink-2); }
.hero-copy .lede { max-width: 34ch; font-size: clamp(17px, 1.5vw, 21px); line-height: 1.5; }
.sub { max-width: 60ch; margin-top: 12px; font-size: 16px; }

.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 34px; }

.hero-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 26px 0 0;
  padding: 0;
  list-style: none;
}

.hero-chips button {
  min-height: 34px;
  padding: 0 15px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.7);
  color: var(--ink-2);
  font-size: 13px;
  font-weight: 600;
  /* Named properties rather than `all`, and the reason is the focus ring.
     `all` transitions outline-width too, so tabbing to a chip animated the
     ring from 0 to 2px over 180ms: at the instant focus arrived there was no
     ring, and a keyboard reader moving at speed never saw one.

     This is also why the first audit of this control reported it as having no
     focus state at all. It has one. It simply was not there yet when the
     sample was taken — and if a machine reading computed style the moment
     after the keypress cannot find the ring, neither can a person. A focus
     indicator is the one thing on a page that has to be instantaneous;
     everything else here can ease. */
  transition:
    border-color 180ms var(--ease),
    background 180ms var(--ease),
    color 180ms var(--ease);
}

.hero-chips button:hover {
  border-color: var(--green);
  color: var(--green-deep);
  background: rgba(13, 159, 110, 0.06);
  transform: translateY(-1px);
}

/* ---------- Hero search ---------- */

/* The dominant control on the page. Buyers arrive with a part code, so this is
   deliberately larger than the header field and reads as the primary action. */
.hero-search {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 10px;
  width: min(100%, 620px);
  margin: 30px 0 0;
  padding: 7px 7px 7px 20px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  /* Ninety-two percent white over the field: there is almost nothing left for
     a blur to show, and it sat over the canvas paying for one every frame. */
  background: rgba(255, 255, 255, 0.96);
  box-shadow: var(--shadow-sm);
  transition: border-color 220ms var(--ease), box-shadow 260ms var(--ease), transform 260ms var(--spring);
}

.hero-search:hover { box-shadow: var(--shadow-md); }

.hero-search:focus-within {
  border-color: rgba(13, 159, 110, 0.55);
  box-shadow: 0 0 0 4px rgba(13, 159, 110, 0.12), var(--shadow-md);
}

.hs-icon {
  width: 20px; height: 20px;
  flex: 0 0 auto;
  fill: none;
  stroke: var(--muted);
  stroke-width: 2;
  stroke-linecap: round;
  transition: stroke 220ms var(--ease);
}

.hero-search:focus-within .hs-icon { stroke: var(--green-deep); }

.hero-search input {
  min-width: 0;
  min-height: 46px;
  border: 0;
  background: none;
  font-size: 17px;
  letter-spacing: 0;
  outline: none;
  appearance: none;
}

.hero-search input::placeholder { color: var(--faint); }
.hero-search input::-webkit-search-cancel-button { appearance: none; }

.hs-go {
  min-height: 46px;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 0 22px;
  border: 0;
  border-radius: 999px;
  background: var(--green);
  color: #fff;
  font-size: 15px;
  font-weight: 600;
  white-space: nowrap;
  box-shadow: 0 2px 10px rgba(13, 159, 110, 0.26);
  transition: background 200ms var(--ease), box-shadow 200ms var(--ease), transform 200ms var(--spring);
}

.hs-go:hover { background: var(--green-deep); box-shadow: 0 6px 18px rgba(13, 159, 110, 0.34); }
.hs-go:active { transform: scale(0.96); }

.hs-go svg {
  width: 16px; height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform 220ms var(--spring);
}

.hs-go:hover svg { transform: translateX(3px); }

/* ---------- Hero secondary row ---------- */

.hero-secondary {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 20px;
  margin-top: 26px;
}

/* Hero panel */

/* The hero panel, rebuilt to the reference's answer rather than to my guess.
 *
 * What stood here was a white glass card holding four product photographs on
 * a rotating clock. The owner circled it as looking wrong and the reference's
 * agent, asked directly, was blunter: "I never put white product tiles on the
 * dark hero. Product photography — which is white-background by rule — appears
 * on the white section below the hero, never floated on the dark wall." The
 * four launch cards are its stated hero content, and their treatment below is
 * copied from its own numbers: 1rem radius, 10% white border, 4% white fill,
 * backdrop blur, and a half-step lift on hover with the border warming to
 * lime. Photography now appears only where its white ground disappears into
 * the page's own. */
.hero-panel {
  position: relative;
  display: grid;
  gap: 12px;
  padding: 18px;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: var(--r-lg);
  /* No backdrop-filter here, and none on the tiles inside. A blurred backdrop
     is recomputed whenever what is behind it changes, and what is behind this
     panel is a canvas that repaints sixty times a second: the browser was
     running a fourteen pixel gaussian over the whole panel, and an eight pixel
     one over each of the four tiles inside it, on every frame of the field.
     That is what made the hero feel heavy, not the orbs. A flat translucent
     ground over a dark field looks the same and costs nothing per frame. */
  background: rgba(12, 20, 36, 0.62);
  box-shadow: none;
}

.hero-panel::before { display: none; }
.panel-glow { display: none; }

.hero-gallery {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
  margin-bottom: 4px;
}

.hero-launch {
  display: grid;
  gap: 6px;
  justify-items: start;
  text-align: left;
  padding: 14px;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.05);
  color: #ffffff;
  transition: transform 300ms var(--ease), border-color 300ms var(--ease), background 300ms var(--ease);
}

.hero-launch:hover {
  transform: translateY(-2px);
  border-color: rgba(184, 231, 106, 0.6);
  background: rgba(255, 255, 255, 0.07);
}

.hero-launch .cat-glyph { width: 30px; height: 30px; }
/* The glyphs are stroke art with no fill of their own; unstyled they inherit a
   black fill and read as blobs on dark glass. Same treatment as the spotlight
   tiles, in lime. */
.hero-launch .cat-glyph .glyph {
  width: 26px;
  height: 26px;
  fill: none;
  stroke: var(--brand-lime);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.hero-launch b { font-size: 13px; font-weight: 600; letter-spacing: 0; }
.hero-launch small { font-size: 12px; color: rgba(255, 255, 255, 0.58); line-height: 1.45; }
.hero-launch em {
  font-style: normal;
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 600;
  color: var(--brand-lime);
}

.hero-points {
  grid-column: 1 / -1;
  display: grid;
  gap: 11px;
  margin: 6px 4px 2px;
  padding: 0;
  list-style: none;
}

.hero-points li {
  display: grid;
  grid-template-columns: 18px minmax(0, 1fr);
  align-items: start;
  gap: 11px;
  color: var(--ink-2);
  font-size: 15px;
  line-height: 1.45;
}

.hero-points li > span {
  position: relative;
  width: 18px; height: 18px;
  margin-top: 2px;
  display: block;
  border-radius: 50%;
  background: rgba(13, 159, 110, 0.12);
}

/* Tick drawn from two borders, so the list needs no icon font or inline SVG. */
.hero-points li > span::before {
  content: "";
  position: absolute;
  left: 5px; top: 5px;
  width: 7px; height: 4px;
  border-left: 1.8px solid var(--green-deep);
  border-bottom: 1.8px solid var(--green-deep);
  transform: rotate(-45deg);
}

.panel-note {
  grid-column: 1 / -1;
  margin: 2px 4px 0;
  color: var(--muted);
  font-size: 13px;
  font-weight: 400;
  line-height: 1.5;
}

/* ---------- Brand logo marquee ---------- */

.logo-marquee {
  position: relative;
  max-width: 100vw;
  contain: paint;
  padding: clamp(18px, 2.5vw, 30px) 0 clamp(36px, 4vw, 56px);
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}

.marquee-track {
  display: flex;
  align-items: center;
  gap: clamp(14px, 1.4vw, 22px);
  width: max-content;
  animation: marquee 52s linear infinite;
}

.logo-marquee:hover .marquee-track { animation-play-state: paused; }

@keyframes marquee {
  to { transform: translateX(-50%); }
}

/* Each mark sits on its own plate, in its own colours, at the size the
   optical-scale sheet gives it. Three changes, all from the same complaint.

   Colour: the strip was grayscale at 72% opacity, which is the convention for
   a logo wall nobody is meant to look at. These are the brands the desk is
   built on, and washing them out to grey made the strip read as a piece of
   furniture rather than as content.

   Size: it ran at 20 to 27px against a wall of 38px marks two sections below,
   so the same logo appeared twice on one page at half the size.

   Plates: the marks now sit on the same light plate the wall uses, which is
   what lets a dark wordmark and a bright one share a row without one of them
   disappearing. */
.marquee-mark {
  display: grid;
  place-items: center;
  min-width: 132px;
  height: 54px;
  padding: 0 16px;
  border: 1px solid var(--line, rgba(0, 0, 0, 0.1));
  border-radius: var(--r-tile, 16px);
  background: #ffffff;
  cursor: pointer;
  transition: transform 260ms var(--ease), box-shadow 260ms var(--ease), border-color 260ms var(--ease);
}

.marquee-mark img {
  width: auto;
  height: auto;
  max-width: 108px;
  max-height: 30px;
  object-fit: contain;
}

.marquee-mark:hover,
.marquee-mark:focus-visible {
  transform: translateY(-2px);
  border-color: rgba(13, 159, 110, 0.45);
  box-shadow: 0 6px 20px rgba(4, 10, 20, 0.14);
}

/* The strip says what it is. Without this it was an unlabelled ribbon and the
   only honest reading of it was that something was scrolling for no reason. */
.marquee-head {
  max-width: var(--measure, 1180px);
  margin: 0 auto clamp(14px, 1.6vw, 22px);
  padding: 0 var(--pad-x);
}

.marquee-head h2 {
  margin: 0 0 4px;
  font-size: clamp(1.05rem, 1.5vw, 1.35rem);
  font-weight: 600;
  letter-spacing: 0;
  color: var(--ink);
}

.marquee-head p {
  margin: 0;
  font-size: 14px;
  color: var(--muted);
}

/* ---------- Sections ---------- */

.section { padding: clamp(56px, 7vw, 104px) var(--pad-x); }
.section-heading { max-width: 820px; margin-bottom: 36px; }

.category-entry { padding-top: clamp(34px, 4.5vw, 72px); }

.category-spotlight {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px;
}

@media (min-width: 1241px) {
  .category-spotlight { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

.category-spotlight button {
  min-width: 0;
  min-height: 168px;
  display: grid;
  align-content: start;
  gap: 10px;
  padding: 20px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: linear-gradient(180deg, #fff, #fbfbfd);
  text-align: left;
  box-shadow: var(--shadow-sm);
  transition: transform 260ms var(--spring), border-color 260ms var(--ease), box-shadow 260ms var(--ease);
}

.category-spotlight button:hover {
  transform: translateY(-4px);
  border-color: rgba(13, 159, 110, 0.24);
  box-shadow: var(--shadow-md);
}

.category-spotlight .cat-glyph {
  width: 42px;
  height: 42px;
  display: grid;
  place-items: center;
  border-radius: 13px;
  background: rgba(13, 159, 110, 0.09);
}

.category-spotlight .cat-glyph .glyph {
  width: 23px;
  height: 23px;
  fill: none;
  stroke: var(--green-deep);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform 320ms var(--spring);
}

.category-spotlight button:hover .cat-glyph .glyph { transform: scale(1.1); }

.category-spotlight b {
  color: var(--ink);
  font-size: 16px;
  line-height: 1.25;
  overflow-wrap: anywhere;
}

.category-spotlight small {
  color: var(--muted);
  font-size: 12px;
  font-weight: 500;
  line-height: 1.45;
}

.category-spotlight em {
  color: var(--green-deep);
  font-size: 11px;
  font-style: normal;
  font-weight: 700;
  letter-spacing: 0;
}

/* ---------- Solutions ---------- */

.solution-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 14px;
}

.solution-card {
  min-height: 224px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 24px;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--panel);
  cursor: pointer;
  transition: transform 340ms var(--spring), box-shadow 340ms var(--ease), border-color 340ms var(--ease);
}

.solution-card:hover {
  transform: translateY(-5px);
  box-shadow: var(--shadow-md);
  border-color: transparent;
}

.solution-card.is-active {
  border-color: var(--green);
  box-shadow: 0 0 0 3px rgba(13, 159, 110, 0.14), var(--shadow-md);
}

/* .solution-index is gone with the "01".."08" chips it drew. The owner's ruling
   on that pattern was explicit: a reader must not be shown the desk's internal
   numbering. The starter cards now lead with their own name. */

.solution-card h3 {
  display: flex;
  align-items: center;
  margin: 0 0 8px;
  font-size: 19px;
  font-weight: 600;
}

.solution-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }

.solution-card ul {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 14px 0 18px;
  padding: 0;
  list-style: none;
}

.solution-card li {
  padding: 4px 11px;
  border-radius: 999px;
  background: var(--canvas);
  color: var(--ink-2);
  font-size: 12px;
  font-weight: 600;
}

.solution-card .button { width: 100%; }

/* ---------- Catalogue ---------- */

.split {
  display: grid;
  grid-template-columns: 296px minmax(0, 1fr);
  gap: 28px;
  align-items: start;
  background: none;
  border: 0;
}

.filters {
  position: sticky;
  top: 106px;
  display: grid;
  gap: 18px;
  padding: 22px;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--panel);
  box-shadow: var(--shadow-sm);
}

.filter-header, .results-toolbar, .drawer-header, .product-title-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}

.filter-header h2, .results-toolbar h2, .drawer-header h2 { margin: 0; }
.filter-header h2, .drawer-header h2 { font-size: 21px; }

.filter-header button { min-height: 32px; padding: 0 14px; font-size: 13px; }

/* Desktop shows the filter panel open; the toggle only exists on narrow screens. */
.filter-body { display: grid; gap: 18px; }
/* Needs to out-specify `.filter-header button`, which sets display:inline-flex. */
.filter-header .filter-toggle { display: none; margin-left: auto; }

.filter-toggle::after {
  content: "";
  width: 7px; height: 7px;
  margin-left: 8px;
  border-right: 1.6px solid currentColor;
  border-bottom: 1.6px solid currentColor;
  transform: rotate(45deg) translateY(-1px);
  transition: transform 200ms var(--ease);
}

.filters.is-open .filter-toggle::after { transform: rotate(225deg) translateY(-2px); }

/* Once a family filter is meaningless (no category or brand chosen) the select
   is disabled rather than offering 700 unscoped options. */
.filters select:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.filter-guide {
  display: grid;
  gap: 8px;
  padding: 12px;
  border: 1px solid rgba(13, 159, 110, 0.14);
  border-radius: 14px;
  background: rgba(13, 159, 110, 0.055);
}

/* The guide is filled in once the catalogue loads. Until then it rendered as a
   hollow green sliver, which looked like a broken box rather than a pending one. */
.filter-guide:empty { display: none; }

/* Stacked, not wrapped: three pills never fit one row of a 226px panel, so the
   third dropped to a second line and the group read as ragged rather than as
   an ordered sequence. */
.filter-steps {
  display: grid;
  gap: 5px;
}

.filter-steps span {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 26px;
  padding: 0 10px 0 6px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.72);
  color: var(--muted);
  font-size: 12px;
  font-weight: 700;
}

/* The 1-2-3 numerals in the filter trail went the same way, and for the same
   reason: they described the desk's own sequence rather than the reader's
   position in it. The trail still shows what has been chosen, which is the
   part that carried information. */

.filter-steps span.is-done {
  background: #fff;
  color: var(--green-deep);
}


.filter-guide p {
  margin: 0;
  color: var(--muted);
  font-size: 13px;
  line-height: 1.4;
}

.filter-guide strong { color: var(--ink); }

.filters label { display: grid; gap: 8px; }

.filters label > span, .seg legend {
  color: var(--faint);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
}

.filters input, .filters select, .sort-wrap select, .rfq-drawer textarea {
  width: 100%;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  background: #fff;
  color: var(--ink);
  outline: none;
  transition: border-color 180ms var(--ease), box-shadow 180ms var(--ease);
}

.filters input:focus, .filters select:focus, .sort-wrap select:focus, .rfq-drawer textarea:focus {
  border-color: var(--green);
  box-shadow: 0 0 0 3.5px rgba(13, 159, 110, 0.16);
}

.filters input, .filters select { height: 42px; padding: 0 13px; }

.filters select, .sort-wrap select {
  appearance: none;
  -webkit-appearance: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236e6e73' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
  background-repeat: no-repeat;
  background-position: right 12px center;
  background-size: 15px;
  padding-right: 36px;
}

.seg { margin: 0; padding: 0; border: 0; display: grid; gap: 8px; }
.seg legend { padding: 0; margin: 0; }

.seg-row {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 3px;
  padding: 3px;
  border-radius: 11px;
  background: rgba(0, 0, 0, 0.055);
}

.seg-row button {
  min-height: 34px;
  border: 0;
  border-radius: 9px;
  background: transparent;
  color: var(--muted);
  font-size: 13px;
  font-weight: 600;
  padding: 0 8px;
  transition: all 200ms var(--ease);
}

.seg-row button.is-on {
  background: #fff;
  color: var(--ink);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}

.quality-note {
  padding-top: 16px;
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: 13px;
  line-height: 1.55;
}

.internal-only { display: none !important; }

/* ---------- BOQ workbench ---------- */

/* ---------- View transitions ----------
   The browser's own cross-fade between two states of the grid. Left near its
   default shape on purpose: this should read as the result set settling, not as
   an effect, so it is short and carries no movement of its own. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 260ms;
  animation-timing-function: var(--ease-out, ease-out);
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) { animation: none; }
}

/* ---------- Scroll-linked motion ----------
   Framer's "transform on scroll" and Motion's scroll timelines, done natively:
   `animation-timeline: view()` ties an animation's progress to where the
   element is in the viewport, with no scroll listener and no library. Browsers
   without it simply skip the block — the content is already in its finished
   state, so nothing depends on the effect running.

   Only applied where it earns its place: the desk columns and the category
   tiles, which are large surfaces a reader scrolls past deliberately. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .category-spotlight button,
    .desk-grid > * {
      animation: scroll-lift linear both;
      animation-timeline: view();
      /* Runs across the element's entry only, and finishes well before the
         middle of the viewport — an effect still resolving under the reader's
         eye is a distraction, not a flourish. */
      animation-range: entry 0% entry 62%;
    }
  }

  @keyframes scroll-lift {
    from { opacity: 0; transform: translateY(28px) scale(0.985); }
    to { opacity: 1; transform: none; }
  }
}

/* ---------- Compare ---------- */

.compare-action {
  flex: 0 0 auto;
  width: 36px;
  display: grid;
  place-items: center;
  /* An 8% black border is invisible at this size, and this button wraps onto a
     line of its own on a narrow card. The result read as a stray quotation
     mark under every product rather than as a control: a 16px grey icon
     floating in white with no edge around it. The stronger line is what makes
     it a button. */
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  background: none;
  cursor: pointer;
  transition: background 180ms var(--ease), border-color 180ms var(--ease), transform 180ms var(--spring);
}

.compare-action svg {
  width: 16px; height: 16px;
  fill: none; stroke: var(--faint); stroke-width: 1.8;
  stroke-linecap: round; stroke-linejoin: round;
}

.compare-action:hover { border-color: rgba(13, 159, 110, 0.4); background: rgba(13, 159, 110, 0.07); }
.compare-action:hover svg { stroke: var(--green-deep); }
.compare-action:active { transform: scale(0.94); }
.compare-action.on { border-color: var(--green); background: rgba(13, 159, 110, 0.14); }
.compare-action.on svg { stroke: var(--green-deep); }

.compare-tray {
  position: fixed;
  left: 50%;
  bottom: 20px;
  z-index: var(--z-tray);
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 12px;
  max-width: calc(100vw - 24px);
  padding: 10px 12px 10px 18px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--panel);
  box-shadow: var(--shadow-lg);
  animation: tray-in var(--spring-bouncy-duration, 620ms) var(--spring-bouncy, var(--spring));
}

@keyframes tray-in {
  from { opacity: 0; transform: translate(-50%, 22px) scale(0.96); }
}

.compare-title {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--faint);
}

.compare-chips { display: flex; gap: 6px; overflow-x: auto; }

.compare-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 6px 5px 11px;
  border-radius: 999px;
  background: var(--plate);
  font-family: var(--mono);
  font-size: 12px;
  white-space: nowrap;
}

.compare-chip button {
  width: 18px; height: 18px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.08);
  color: var(--muted);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
}

.compare-chip button:hover { background: rgba(0, 0, 0, 0.16); color: var(--ink); }
.compare-tray .button { min-height: 34px; padding: 0 16px; font-size: 13px; }
.compare-tray .button:disabled { opacity: 0.5; cursor: default; }

.compare-dismiss {
  border: 0;
  background: none;
  color: var(--faint);
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
}

.compare-dismiss:hover { color: var(--ink); }

.compare-view { padding: clamp(22px, 3vw, 40px); }
.compare-view h2 { margin: 0 0 18px; }
.compare-scroll { overflow-x: auto; }

.compare-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.compare-table th, .compare-table td {
  padding: 12px 14px;
  border-bottom: 1px solid var(--line);
  text-align: left;
  vertical-align: top;
}
.compare-table thead th { border-bottom: 2px solid var(--line-strong); }
.compare-table thead th strong { display: block; margin-top: 8px; font-size: 14px; }
.compare-table thead th code { font-size: 11px; color: var(--faint); }
.compare-table tbody th { width: 150px; color: var(--muted); font-weight: 600; }
.compare-media { display: block; height: 96px; }
.compare-media img { width: 100%; height: 100%; object-fit: contain; }
.compare-media .no-photo-plate { height: 96px; padding: 8px; }
.compare-media .no-photo-plate .plate-family,
.compare-media .no-photo-plate .plate-group { display: none; }

/* Rows where the products actually differ are the reason to be here. */
.compare-table tr.differs td { background: rgba(13, 159, 110, 0.055); font-weight: 600; }
.compare-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; }

/* ---------- Shortcuts sheet ---------- */

.shortcuts {
  width: min(460px, calc(100vw - 32px));
  padding: 26px 28px;
  border: 0;
  border-radius: var(--r-lg);
  background: var(--panel);
  box-shadow: var(--shadow-lg);
}

.shortcuts::backdrop { background: rgba(12, 18, 24, 0.4); backdrop-filter: blur(6px); }
.shortcuts h2 { margin: 0 0 16px; font-size: 20px; }
.shortcuts dl { margin: 0 0 20px; display: grid; gap: 10px; }
.shortcuts dl > div { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.shortcuts dt { display: flex; gap: 4px; }
.shortcuts dd { margin: 0; color: var(--ink-2); font-size: 14px; }

.shortcuts kbd,
.shortcuts-key {
  min-width: 24px;
  padding: 3px 7px;
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  background: var(--plate);
  font-family: inherit;
  font-size: 12px;
  font-weight: 600;
  text-align: center;
}

/* ---------- Copyable part code ---------- */

.detail-sku {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 4px 10px;
  margin: 0 0 10px;
  border: 1px solid transparent;
  border-radius: 8px;
  background: var(--plate);
  color: var(--muted);
  font-family: var(--mono);
  font-size: 13px;
  cursor: copy;
  transition: border-color 180ms var(--ease), color 180ms var(--ease);
}

.detail-sku span { opacity: 0; font-size: 12px; transition: opacity 180ms var(--ease); }
.detail-sku:hover { border-color: var(--line-strong); color: var(--ink); }
.detail-sku:hover span { opacity: 0.7; }

/* ---------- Back to top ---------- */

.to-top {
  position: fixed;
  right: 20px;
  bottom: 96px;
  z-index: var(--z-suggest);
  width: 42px; height: 42px;
  display: grid;
  place-items: center;
  /* An 8% black border is invisible at this size, and this button wraps onto a
     line of its own on a narrow card. The result read as a stray quotation
     mark under every product rather than as a control: a 16px grey icon
     floating in white with no edge around it. */
  border: 1px solid var(--line-strong);
  border-radius: 50%;
  background: var(--panel);
  box-shadow: var(--shadow-sm);
  cursor: pointer;
  animation: fade-in 240ms var(--ease-out, var(--ease));
  transition: transform 220ms var(--spring), box-shadow 220ms var(--ease);
}

.to-top svg { width: 18px; height: 18px; fill: none; stroke: var(--ink-2); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.to-top:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.to-top:active { transform: scale(0.94); }

@media (max-width: 620px) {
  .to-top { bottom: 84px; right: 14px; }
  .compare-tray { bottom: 12px; padding-left: 14px; gap: 8px; }
  .compare-title { display: none; }
}

/* ---------- Command palette ---------- */

.palette {
  position: fixed;
  inset: 0;
  z-index: var(--z-palette);
  display: grid;
  place-items: start center;
  padding: clamp(60px, 12vh, 140px) 16px 16px;
  background: rgba(12, 18, 24, 0.88);
  animation: fade-in 180ms var(--ease-out, var(--ease));
}

@keyframes fade-in { from { opacity: 0; } }

.palette-card {
  width: min(680px, 100%);
  max-height: min(70vh, 620px);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border-radius: var(--r-lg);
  background: var(--panel);
  box-shadow: var(--shadow-lg);
  animation: palette-in var(--spring-bouncy-duration, 620ms) var(--spring-bouncy, var(--spring));
}

@keyframes palette-in {
  from { opacity: 0; transform: translateY(-16px) scale(0.97); }
}

.palette-field {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px 18px;
  border-bottom: 1px solid var(--line);
}

.palette-field svg {
  width: 20px; height: 20px;
  fill: none; stroke: var(--faint); stroke-width: 1.9;
  stroke-linecap: round;
}

.palette-field input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: none;
  font-size: 17px;
  color: var(--ink);
  outline: none;
}

.palette-field input::placeholder { color: var(--faint); }

.palette-field button {
  padding: 5px 10px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: none;
  color: var(--faint);
  font-size: 11px;
  font-weight: 600;
}

.palette-results { overflow-y: auto; padding: 8px; }

.palette-group {
  margin: 12px 10px 6px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--faint);
}

.palette-row {
  width: 100%;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  padding: 11px 12px;
  border: 0;
  border-radius: 10px;
  background: none;
  text-align: left;
  cursor: pointer;
}

/* Highlight follows the keyboard, so it must not also follow the mouse — two
   competing highlights read as a bug. */
.palette-row.is-active { background: rgba(13, 159, 110, 0.11); }
.palette-label { font-size: 15px; font-weight: 600; color: var(--ink); }
.palette-hint { flex: 0 0 auto; font-size: 12px; color: var(--faint); font-family: var(--mono); }
.palette-empty { padding: 26px 16px; margin: 0; color: var(--muted); font-size: 14px; text-align: center; }

.palette-foot {
  margin: 0;
  padding: 10px 16px;
  border-top: 1px solid var(--line);
  font-size: 11px;
  color: var(--faint);
}

.palette-foot kbd {
  display: inline-block;
  min-width: 18px;
  padding: 1px 5px;
  margin-right: 3px;
  border: 1px solid var(--line);
  border-radius: 5px;
  background: var(--plate);
  font-family: inherit;
  font-size: 11px;
  text-align: center;
}

body.palette-open { overflow: hidden; }

/* ---------- Signals + Studio ----------
   Two columns of the desk's own content. Signals is a reading column, so it is
   set narrow and quiet; Studio is a rail of artwork that moves on its own. */

.section.desk-columns {
  background: var(--mesh-grad);
  border-radius: clamp(24px, 3vw, 40px);
}

/* One column of content since the poster rail left. Capping the band at a
   readable measure left half the section empty, which reads as a missing
   thing rather than as space, so the notes themselves take the width: two
   columns of cards on a wide screen, each its own comfortable measure, one
   column as soon as that stops being true. */
.desk-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: clamp(24px, 3vw, 52px);
  align-items: start;
}

.column-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding-bottom: 12px;
  margin-bottom: 18px;
  border-bottom: 1px solid var(--line);
}

.column-head h3 { margin: 0; font-size: 20px; letter-spacing: 0; }

.column-note {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--faint);
}

.signal-list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 10px;
  grid-template-columns: minmax(0, 1fr);
}

@media (min-width: 1080px) {
  .signal-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
  /* Each card carries its own height: a longer note beside a shorter one
     leaves a ragged edge rather than stretching one to match the other. */
  .signal-list > li { align-self: start; }
}

.signal {
  padding: 18px 20px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--panel);
  animation: card-in var(--spring-smooth-duration, 560ms) var(--spring-smooth, var(--ease)) backwards;
  animation-delay: var(--enter-delay, 0ms);
  transition: border-color 240ms var(--ease), box-shadow 240ms var(--ease), transform 240ms var(--spring);
}

.signal:hover {
  border-color: rgba(13, 159, 110, 0.34);
  box-shadow: var(--shadow-sm);
  transform: translateY(-2px);
}

.signal-meta {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 8px;
  font-size: 11px;
  color: var(--faint);
}

.signal-tag {
  padding: 3px 9px;
  border-radius: 999px;
  background: rgba(13, 159, 110, 0.10);
  color: var(--green-deep);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.signal h4 { margin: 0 0 6px; font-size: 17px; line-height: 1.3; letter-spacing: 0; }
.signal p { margin: 0; color: var(--ink-2); font-size: 14px; line-height: 1.55; }

.signal-actions {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-top: 12px;
  font-size: 13px;
}

/* Every colour here is a variable rather than a literal, and that is the whole
   point. This button was written for a paper card and shipped on a dark one:
   near-black ink on a dark band, 1.16:1, invisible until you knew where to
   look. Overriding it from the band would have meant re-stating the hover and
   active rules too, which is how the same bug comes back the next time
   somebody edits one copy. The band redefines the four variables and nothing
   else. */
.signal-actions button {
  --act-face: rgba(0, 0, 0, 0.055);
  --act-ink: var(--ink);
  --act-face-hover: rgba(13, 159, 110, 0.14);
  --act-ink-hover: var(--green-deep);
  padding: 6px 14px;
  border: 0;
  border-radius: 999px;
  background: var(--act-face);
  color: var(--act-ink);
  font-size: 13px;
  font-weight: 600;
  transition: background 180ms var(--ease), color 180ms var(--ease), transform 180ms var(--spring);
}

.signal-actions button:hover { background: var(--act-face-hover); color: var(--act-ink-hover); }
.signal-actions button:active { transform: scale(0.96); }
.signal-actions a { color: var(--faint); font-weight: 600; }
.signal-actions a:hover { color: var(--green-deep); }

.ink-band .signal-actions button {
  --act-face: rgba(255, 255, 255, 0.1);
  --act-ink: #ffffff;
  --act-face-hover: rgba(184, 231, 106, 0.2);
  --act-ink-hover: #ffffff;
}

.ink-band .signal-actions a { color: rgba(233, 240, 250, 0.7); }
.ink-band .signal-actions a:hover { color: var(--brand-lime); }


@media (max-width: 900px) {
  .desk-grid { grid-template-columns: minmax(0, 1fr); }
}

@media (prefers-reduced-motion: reduce) {
  /* A rail that moves on its own is exactly what this preference is about. */
}

.boq-workbench { padding-top: 30px; }

.boq-panel {
  display: grid;
  grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
  gap: 18px;
  align-items: stretch;
}

.boq-input,
.boq-output {
  min-width: 0;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--panel);
  box-shadow: var(--shadow-sm);
}

.boq-input {
  display: grid;
  gap: 14px;
  padding: 22px;
}

.boq-input label,
.boq-output-head h3 {
  margin: 0;
  color: var(--ink);
  font-size: 16px;
  font-weight: 700;
}

.boq-input textarea {
  min-height: 250px;
  width: 100%;
  resize: vertical;
  padding: 16px;
  border: 1px solid var(--line-strong);
  border-radius: 18px;
  background: #fff;
  color: var(--ink);
  line-height: 1.45;
}

.boq-input textarea:focus {
  border-color: rgba(13, 159, 110, 0.48);
  box-shadow: 0 0 0 4px rgba(13, 159, 110, 0.1);
  outline: 0;
}

.boq-tools {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.file-button {
  min-height: 42px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 16px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.05);
  color: var(--ink);
  font-weight: 600;
  cursor: pointer;
}

.file-button input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.boq-output {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  overflow: hidden;
}

.boq-output-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 18px 20px;
  border-bottom: 1px solid var(--line);
}

.boq-matches {
  display: grid;
  gap: 0;
  max-height: 430px;
  overflow: auto;
}

.boq-matches > p {
  margin: 0;
  padding: 22px;
  color: var(--muted);
}

.boq-match {
  display: grid;
  grid-template-columns: 54px minmax(0, 1fr) auto;
  gap: 12px;
  align-items: center;
  padding: 14px 18px;
  border-bottom: 1px solid var(--line);
  cursor: pointer;
}

.boq-match:hover { background: rgba(0, 0, 0, 0.025); }
.boq-match h4 { margin: 0 0 3px; font-size: 14px; font-family: var(--mono); }
.boq-match p { margin: 0; color: var(--ink-2); font-size: 13px; line-height: 1.35; }
.boq-match small { display: block; margin-top: 4px; color: var(--faint); font-size: 11px; overflow-wrap: anywhere; }
.boq-match > span { color: var(--green-deep); font-weight: 700; }

.boq-unmatched {
  padding: 14px 20px 18px;
  background: #fbfbfd;
  border-top: 1px solid var(--line);
  color: var(--muted);
}

.boq-unmatched strong {
  display: block;
  margin-bottom: 8px;
  color: var(--ink);
  font-size: 13px;
}

.boq-unmatched p {
  margin: 4px 0;
  font-size: 12px;
  overflow-wrap: anywhere;
}

.catalogue-results { min-width: 0; }
.results-toolbar { margin-bottom: 16px; flex-wrap: wrap; }
.results-toolbar .eyebrow { margin-bottom: 6px; }

/* Section headings run to 52px, which is right for a statement and wrong for a
   result count sitting beside a sort control. */
.results-toolbar h2 {
  font-size: clamp(26px, 2.4vw, 34px);
  letter-spacing: 0;
  font-variant-numeric: tabular-nums;
}

.result-note {
  margin: 5px 0 0;
  color: var(--muted);
  font-size: 13px;
  font-variant-numeric: tabular-nums;
}
.toolbar-actions { display: flex; gap: 10px; align-items: center; }

.sort-wrap { display: inline-flex; }
.sort-wrap select {
  height: 40px;
  min-width: 195px;
  border: 0;
  border-radius: 999px;
  background-color: rgba(0, 0, 0, 0.05);
  font-weight: 600;
  font-size: 14px;
  padding-left: 16px;
}

/* Filter chips */

.chip-row { display: flex; flex-wrap: wrap; gap: 8px; min-height: 0; margin-bottom: 16px; }
.chip-row:empty { display: none; }

.chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  padding: 0 6px 0 13px;
  border-radius: 999px;
  background: rgba(13, 159, 110, 0.1);
  color: var(--green-deep);
  font-size: 13px;
  font-weight: 600;
  animation: pop-in 220ms var(--spring);
}

.chip b { color: rgba(8, 122, 84, 0.6); font-weight: 600; }

.chip button {
  width: 21px; height: 21px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(8, 122, 84, 0.14);
  color: var(--green-deep);
  font-size: 13px;
  line-height: 1;
  transition: background 140ms var(--ease);
}

.chip button:hover { background: rgba(8, 122, 84, 0.28); }

/* ---------- Product detail ---------- */

.detail-head {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

.detail-copy h2 {
  margin: 14px 0 6px;
  font-size: clamp(22px, 2.4vw, 29px);
  line-height: 1.18;
  letter-spacing: 0;
}

.detail-sku {
  margin: 0;
  color: var(--ink-2);
  font-family: var(--mono);
  font-size: 13px;
}

.detail-tags { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 12px; }

.detail-tags .avail {
  display: inline-flex;
  align-items: center;
  min-height: 24px;
  padding: 0 10px;
  border: 1px solid rgba(10, 163, 109, 0.2);
  border-radius: 999px;
  background: rgba(10, 163, 109, 0.08);
  color: var(--green-deep);
  font-size: 11px;
  font-weight: 700;
}

.detail-tags .avail.tone-neutral {
  border-color: var(--line-strong);
  background: rgba(0, 0, 0, 0.035);
  color: var(--muted);
}

.detail-cta {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 20px 0 8px;
}

.detail-phone { margin: 0 0 4px; color: var(--muted); font-size: 13px; }
.detail-phone a { color: var(--green-deep); font-weight: 600; }

.detail-section {
  margin-top: 22px;
  padding-top: 20px;
  border-top: 1px solid var(--line);
}

.detail-section h3 {
  margin: 0 0 8px;
  color: var(--muted);
  font-size: 13px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.detail-section p {
  margin: 0;
  color: var(--ink);
  font-size: 15px;
  line-height: 1.65;
}

/* Provenance for quoted manufacturer copy: the buyer can see where the text came
   from and open the page it was taken from. */
.detail-section p.detail-source {
  margin-top: 10px;
  color: var(--muted);
  font-size: 13px;
  line-height: 1.5;
}

.detail-source a {
  color: var(--green-deep);
  font-weight: 600;
  text-decoration: underline;
  text-decoration-color: rgba(13, 159, 110, 0.3);
  text-underline-offset: 2px;
}

.detail-source a:hover { text-decoration-color: var(--green); }

.resource-links {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.resource-links a {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: 10px 14px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: #fff;
  color: var(--ink);
  box-shadow: var(--shadow-xs);
  font-size: 13px;
  font-weight: 700;
  text-decoration: none;
}

.resource-links a:hover {
  border-color: rgba(0, 166, 118, 0.4);
  color: var(--green-deep);
}

.resource-note {
  margin: 18px 0 0;
  padding: 12px 14px;
  border: 1px solid rgba(13, 159, 110, 0.16);
  border-radius: 16px;
  background: rgba(13, 159, 110, 0.055);
  color: var(--muted);
  font-size: 12.8px;
  line-height: 1.55;
}

/* auto-fit, so a product with a single spec gets a full-width row instead of a
   half-width card next to an empty column. */
.spec-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  gap: 8px;
  margin: 0;
}

.spec-list div {
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: rgba(247, 249, 251, 0.72);
}

.spec-list dt {
  margin-bottom: 4px;
  color: var(--muted);
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.spec-list dd {
  margin: 0;
  color: var(--ink);
  font-size: 14px;
  font-weight: 600;
  overflow-wrap: anywhere;
}

.detail-utils { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }

.detail-utils button {
  min-height: 34px;
  padding: 0 13px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: #fff;
  color: var(--ink-2);
  font-size: 13px;
  font-weight: 600;
}

.detail-utils button:hover { border-color: var(--green); color: var(--green-deep); }

/* ---------- Project-starter recommendation ----------
   Shown for review above the grid. It never mutates the RFQ on its own. */

.recommendation {
  display: grid;
  gap: 12px;
  margin-bottom: 18px;
  padding: 18px;
  border: 1px solid rgba(13, 159, 110, 0.2);
  border-radius: var(--r-lg);
  background: rgba(13, 159, 110, 0.045);
  animation: pop-in 260ms var(--spring);
}

.recommendation[hidden] { display: none; }

.rec-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
}

.rec-head h3 { margin: 0; font-size: 19px; }
.rec-actions { display: flex; flex-wrap: wrap; gap: 8px; }

.rec-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.rec-list li {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

.rec-list button {
  flex: 1;
  min-width: 0;
  display: grid;
  gap: 2px;
  padding: 9px 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: #fff;
  text-align: left;
  transition: border-color 180ms var(--ease), box-shadow 180ms var(--ease);
}

.rec-list button:hover { border-color: var(--green); box-shadow: var(--shadow-sm); }
.rec-list b { font-family: var(--mono); font-size: 12px; }
.rec-list small { color: var(--muted); font-size: 11px; }

.rec-in {
  flex: 0 0 auto;
  color: var(--green-deep);
  font-size: 11px;
  font-weight: 700;
}

.rec-note { margin: 0; color: var(--muted); font-size: 13px; }

/* ---------- Product cards ---------- */

.product-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 16px;
}

.product-card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--panel);
  transition: transform 380ms var(--spring), box-shadow 380ms var(--ease), border-color 380ms var(--ease);
  /* Entrance runs on the no-overshoot spring: a dense grid of cards that each
     overshoot reads as wobble, not polish. `backwards` holds the card hidden
     through its stagger delay instead of flashing it first. */
  animation: card-in var(--spring-smooth-duration, 560ms) var(--spring-smooth, var(--ease)) backwards;
  animation-delay: var(--enter-delay, 0ms);
}

/* Cards that were already on screen before this render — see renderProducts. */
.product-card.is-settled { animation: none; }

/* A card without a photo keeps the same shape as one with a photo: the
   category plate fills the frame, so the grid stays flush either way. */

@keyframes card-in {
  from { opacity: 0; transform: translateY(14px) scale(0.985); }
}

.product-card:hover {
  transform: translateY(-6px);
  border-color: transparent;
  box-shadow: var(--shadow-lg);
}

.image-frame {
  position: relative;
  width: 100%;
  height: 236px;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  padding: 14px;
  border: 0;
  border-bottom: 1px solid var(--line);
  background: var(--plate);
  color: inherit;
  text-decoration: none;
}

.image-frame img {
  width: 100%; height: 100%;
  object-fit: contain;
  align-self: center;
  display: block;
  transform: scale(1);
  transform-origin: center;
  transition: transform 700ms var(--spring);
}

.product-card:hover .image-frame img { transform: scale(1.035); }

/* What kind of picture this is, said quietly.
   The desk's rule is that a render is never presented as an official
   photograph. That was true in the metadata and in a disclosure line at the
   foot of the page, which nobody reads. On a firewall search where twenty rows
   carry the same illustration, the silence is what made the grid read as a
   placeholder catalogue: nothing on the card admitted what the picture was.
   Saying it turns an apparent weakness into a plain statement.
   An exact photograph gets no badge. It is the default and the best case, and
   labelling it would be noise on the cards that need no explaining. */
.image-kind {
  position: absolute;
  z-index: 2;
  left: 10px;
  bottom: 10px;
  padding: 3px 8px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.82);
  border: 1px solid rgba(15, 23, 42, 0.08);
  color: var(--muted);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0;
  line-height: 1.4;
  backdrop-filter: blur(6px);
  pointer-events: none;
  opacity: 0.85;
  transition: opacity 220ms var(--ease);
}
.product-card:hover .image-kind { opacity: 1; }

/* A family photograph is doing more work than an exact one, so the frame says
   so with a tint rather than pretending the repetition is not there. Twenty
   identical renders down a page look like a mistake; twenty identical renders
   that each say "family photo" on a tinted plate look like a convention. */
.image-frame.kind-family { background: linear-gradient(180deg, #fbfcfe 0%, #f4f7fb 100%); }
.image-frame.kind-illustration { background: linear-gradient(180deg, #fbfbfd 0%, #f5f4f9 100%); }

/* Renders and family shots are drawn small inside a large frame, so they sat in
   a lake of white. Letting them fill more of the box costs nothing and closes
   the gap between a card that has a photograph and one that does not. */
.image-frame.kind-illustration img,
.image-frame.kind-family img { transform: scale(1.06); }
.product-card:hover .image-frame.kind-illustration img,
.product-card:hover .image-frame.kind-family img { transform: scale(1.09); }

/* Photos settle in as they decode. A lazily-loaded grid otherwise snaps each
   image to full opacity the instant it arrives, which reads as flicker while
   scrolling past. Deliberately an animation on the `loaded` class rather than a
   default-hidden image: if that class never arrives, the photo is still
   visible. renderProducts sets it — synchronously for images already cached. */
.image-frame img.loaded { animation: photo-in 420ms var(--ease-out, var(--ease)) backwards; }

@keyframes photo-in {
  from { opacity: 0; transform: scale(1.02); }
}

/* Same height as a photo frame — a shorter pending frame made the grid ragged. */
.image-frame.pending {
  height: 236px;
  background: var(--plate);
}

/* No-photo state. The old dashed box with a cross read as "image failed to
   load"; this is a deliberate category plate instead — quiet, aligned, and
   honest that the photo is available on request. */
.no-photo-plate {
  position: relative;
  width: 100%; height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 20px;
  overflow: hidden;
  border-radius: var(--r-md);
  background:
    linear-gradient(158deg, #fdfdfe 0%, #f4f6f9 52%, #eef1f5 100%);
  color: var(--muted);
  text-align: center;
}

/* A soft off-centre highlight, so the plate reads as a designed surface rather
   than a flat grey box waiting for content. */
.no-photo-plate .plate-glow {
  position: absolute;
  inset: -30% -20% auto -20%;
  height: 150%;
  background: radial-gradient(50% 44% at 32% 22%, rgba(13, 159, 110, 0.09), transparent 72%);
  pointer-events: none;
}

.no-photo-plate .glyph {
  position: relative;
  width: 54px;
  height: 54px;
  fill: none;
  stroke: #98a4b0;
  stroke-width: 1.25;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: stroke 300ms var(--ease), transform 500ms var(--spring);
}

.product-card:hover .no-photo-plate .glyph {
  stroke: var(--green);
  transform: translateY(-2px) scale(1.04);
}

.no-photo-plate .plate-family {
  position: relative;
  max-width: 100%;
  color: var(--ink-2);
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  overflow-wrap: anywhere;
}

.no-photo-plate .plate-group {
  position: relative;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0.62;
}

/* ---------- Licence plate ----------
   Licences, subscriptions, support and services have no physical form. The
   plate draws an entitlement card carrying the issuing manufacturer's mark:
   card-shaped so it can never read as a product photograph, branded so the
   buyer still knows whose entitlement it is. */

.no-photo-plate.is-licence {
  background: linear-gradient(158deg, #fbfcfe 0%, #eef2f7 55%, #e7ecf3 100%);
  gap: 12px;
}

.no-photo-plate.is-licence .plate-glow {
  background: radial-gradient(50% 44% at 30% 18%, rgba(24, 96, 180, 0.10), transparent 74%);
}

/* A sourcing route is not a unit either, so it gets type rather than a picture:
   the brand set between two rules, in the same restrained register as the
   entitlement card. The two together teach a reader that a structured plate
   means "this row is not one object", which is exactly what is true. Warm grey
   rather than the licence card's cool blue, so the two are told apart at a
   glance without either shouting. */
.no-photo-plate.is-route {
  background: linear-gradient(158deg, #fcfcfb 0%, #f2f1ee 55%, #ebe9e5 100%);
  gap: 12px;
}

.no-photo-plate.is-route .plate-glow {
  background: radial-gradient(50% 44% at 30% 18%, rgba(120, 96, 40, 0.09), transparent 74%);
}

.route-card {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 12px;
  width: min(80%, 210px);
}

.route-rule {
  height: 1px;
  background: linear-gradient(90deg, transparent, rgba(28, 34, 46, 0.22), transparent);
}

.route-brand {
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: #3d4453;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 150px;
}

.licence-card {
  position: relative;
  display: grid;
  place-items: center;
  width: min(74%, 190px);
  aspect-ratio: 1.586;               /* ID-1: the proportion every card shares */
  padding: 12px 16px;
  border-radius: 10px;
  background: linear-gradient(147deg, #ffffff 0%, #f4f7fb 46%, #e8eef6 100%);
  border: 1px solid rgba(19, 42, 68, 0.10);
  box-shadow:
    0 1px 1px rgba(16, 34, 56, 0.05),
    0 10px 22px -12px rgba(16, 34, 56, 0.35);
  overflow: hidden;
  transition: transform var(--spring-smooth-duration, 600ms) var(--spring-smooth, var(--spring)),
    box-shadow 320ms var(--ease);
}

/* A single diagonal highlight: enough to read as a laminated surface without
   turning into a novelty gradient. */
.licence-sheen {
  position: absolute;
  inset: 0;
  background: linear-gradient(118deg, transparent 30%, rgba(255, 255, 255, 0.85) 47%, transparent 62%);
  opacity: 0.7;
}

/* The contact pad every entitlement card in the world carries. Purely a shape
   cue — it names no scheme and imitates no issuer. */
.licence-chip {
  position: absolute;
  top: 14%;
  left: 11%;
  width: 17%;
  aspect-ratio: 1.32;
  border-radius: 3px;
  background: linear-gradient(155deg, #e8d9a8, #c9b16a 52%, #a98f47);
  box-shadow: inset 0 0 0 0.5px rgba(120, 96, 40, 0.5);
}

.licence-chip::after {
  content: "";
  position: absolute;
  inset: 26% 18%;
  border: 0.5px solid rgba(120, 96, 40, 0.55);
  border-left: 0;
  border-right: 0;
}

.licence-mark-slot {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  padding: 0 6%;
  margin-top: 6%;
}

/* The brand's own asset, sized to sit on the card rather than fill it. */
.licence-mark-slot .brand-logo,
.licence-mark-slot .subbrand-logo {
  max-width: 100%;
  max-height: 34px;
  width: auto;
  height: auto;
  opacity: 0.92;
}

.licence-mark-slot .brand-logo.sq { max-height: 30px; }

/* Brands with no approved logo asset set their name instead — never an
   improvised mark. */
.licence-mark-slot .brand-line {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--ink-2);
  text-align: center;
  line-height: 1.25;
}

.licence-rule {
  position: absolute;
  left: 11%;
  right: 11%;
  bottom: 16%;
  height: 4px;
  border-radius: 2px;
  background: repeating-linear-gradient(90deg,
    rgba(19, 42, 68, 0.16) 0 12px, transparent 12px 18px);
}

.product-card:hover .licence-card {
  transform: translateY(-3px) rotate(-0.6deg);
  box-shadow:
    0 2px 3px rgba(16, 34, 56, 0.06),
    0 18px 34px -14px rgba(16, 34, 56, 0.42);
}

/* The sheet shows the same card, larger. */
.detail-media .licence-card { width: min(66%, 300px); }
.detail-media .licence-mark-slot .brand-logo { max-height: 54px; }

/* Small glyph plate for RFQ rows, BOQ matches, and related products. */
.rfq-thumb.pending {
  display: grid;
  place-items: center;
  background: linear-gradient(180deg, #fcfcfd, #f4f6f8);
}

.rfq-thumb.pending .glyph {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: var(--line-strong);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.photo-badge {
  position: absolute;
  left: 12px; top: 12px;
  z-index: 3;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 0;
  border: 0;
  padding: 5px 11px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(10px);
  color: var(--ink-2);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  box-shadow: var(--shadow-sm);
}

.photo-badge::before {
  content: "";
  width: 5px; height: 5px;
  border-radius: 50%;
  background: var(--green);
}

.photo-badge.pending::before { background: #f0a03c; }
.photo-badge.use_family_image { color: #5f6f7f; }
.photo-badge.use_family_image::before { background: #7a8794; }
.photo-badge.use_official_candidate_image { color: #1769aa; }
.photo-badge.use_official_candidate_image::before { background: #2483d1; }

.brand-ribbon { display: none; }

.product-body {
  flex: 1;
  display: grid;
  gap: 7px;
  align-content: start;
  padding: 16px 18px 10px;
}

/* A fixed height, not a minimum.
   The marks are deliberately different heights so their ink carries equal
   weight: 22px for a one-line wordmark, 26px for a square glyph, 32px for a
   two-line lockup. With `min-height` that difference escaped the row and made
   the body below it start at three different places, so cards sitting side by
   side in the same grid row were subtly out of register down their whole
   length. The row is now the height of the tallest mark and every mark centres
   in it, so the marks keep their optical weight and the cards keep their
   baseline. */
.brand-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  height: 32px;
}

.line-label {
  color: var(--muted);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.product-card h3 {
  margin: 2px 0 0;
  font-size: 16px;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: 0;
}

/* Two lines maximum: a single long part name otherwise stretches every card in
   its row, which is what made the grid look uneven. */
.product-card h3 a {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  color: var(--ink);
}
.product-card h3 a:hover { color: var(--green-deep); }

/* The part code is what a buyer scans for, so it gets one line and keeps it.
   `overflow-wrap: anywhere` let a long code break mid-token across two lines,
   which is the opposite of scannable: QNAP-PFSENSE-OPEN-SOURCE-FIREWALL-ON-QNAP
   wrapped after "FIREWALL-ON-" and the eye lost the row. One line, ellipsis at
   the end, and the full code on the title attribute and in the copy button, so
   nothing is actually lost. Letter-spacing because a mono face at 11px in a
   dense grid needs the air to stay legible at a glance. */
.product-card .sku {
  align-self: start;
  max-width: 100%;
  padding: 3px 9px;
  border-radius: 7px;
  background: rgba(0, 0, 0, 0.045);
  color: var(--ink-2);
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: 0.01em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.product-family, .product-meta {
  margin: 0;
  color: var(--muted);
  font-size: 13px;
  line-height: 1.45;
}

.card-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 4px;
}

.card-tags .avail {
  display: inline-flex;
  align-items: center;
  min-height: 21px;
  padding: 0 8px;
  border: 1px solid rgba(10, 163, 109, 0.18);
  border-radius: 999px;
  background: rgba(10, 163, 109, 0.075);
  color: var(--green-deep);
  font-size: 10px;
  font-weight: 700;
}

.card-tags .avail.tone-neutral {
  border-color: var(--line-strong);
  background: rgba(0, 0, 0, 0.03);
  color: var(--muted);
}

/* One primary action, two quiet ones — the previous three equal-weight buttons
   gave the card no focal point. */
/* Two rows, on purpose, rather than four controls fighting over one.
   This said `1fr auto auto` while the card has rendered four children ever
   since compare was added, so the compare button dropped to an implicit second
   row on its own: a lone 36px glyph under three pills with dead space around
   it, on every card in the grid. Measured, a 100px row holding a 38px control.
   Four equal columns fixed the orphan and created a worse problem: "Add to
   quote" was left 73px to say twelve characters in.
   So the split is stated instead of stumbled into. Adding to the quote is the
   one thing this page exists to make easy, and it gets the full width. The
   three ways of finding out more share the row underneath. */
.card-actions {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  grid-template-areas:
    "primary primary primary"
    "details ask     compare";
  align-items: center;
  gap: 8px;
  padding: 0 18px 16px;
}

.card-actions .primary-action { grid-area: primary; }
.card-actions .view-action { grid-area: details; }
.card-actions .wa-action { grid-area: ask; }
.card-actions .compare-action { grid-area: compare; }

.card-actions button,
.card-actions a {
  min-height: 38px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 14px;
  border: 1px solid transparent;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
  transition: background 180ms var(--ease), color 180ms var(--ease), border-color 180ms var(--ease), transform 160ms var(--spring);
}

.card-actions .primary-action {
  background: var(--ink);
  color: #fff;
}

.card-actions .primary-action:hover { background: var(--green-deep); }
.card-actions .primary-action:active { transform: scale(0.97); }
.card-actions .primary-action.added { background: rgba(10, 163, 109, 0.12); color: var(--green-deep); }

.card-actions .view-action,
.card-actions .wa-action {
  border-color: var(--line-strong);
  background: #fff;
  color: var(--ink-2);
}

.card-actions .view-action:hover,
.card-actions .wa-action:hover { border-color: var(--green); color: var(--green-deep); }


/* Brand marks sit inside a fixed window so a wide wordmark and a square glyph
   occupy the same visual weight on every card. */
.brand-logo {
  height: 22px;
  width: auto;
  max-width: 132px;
  object-fit: contain;
  object-position: left center;
}

.brand-logo.sq { height: 26px; max-width: 34px; }

/* A two-line lockup carries a tagline under the wordmark, so at the shared 22px
   the wordmark itself lands about 14px and reads as a smudge next to Logitech
   or Fortinet set on one line. Taller box, same optical weight. */
.brand-logo.stacked { height: 32px; max-width: 116px; }

/* CommScope's two lines are the only marks in this catalogue that are not
   SVG, and until now they were also the only ones with their own padding baked
   into the file: the NETCONNECT lockup was 600x315 with the actual wordmark
   occupying 40% of it, so inside an 88x46 box the mark rendered about 55px
   across. The owner read that as a missing logo, on two of the largest brands
   on the site. The files are trimmed to their ink now and the box is sized for
   what a two-line lockup needs rather than for a single-line wordmark. */
.subbrand-logo {
  height: 34px;
  width: auto;
  max-width: 168px;
  object-fit: contain;
  object-position: left center;
}

.subbrand-logo.systimax { height: 36px; }

.brand-line {
  color: var(--ink-2);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0;
}

/* Skeletons + empty state */

.skeleton {
  height: 430px;
  border: 0;
  border-radius: var(--r-lg);
  background: linear-gradient(100deg, #ececf0 32%, #f6f6f8 50%, #ececf0 68%);
  background-size: 220% 100%;
  animation: shimmer 1.3s linear infinite;
}

@keyframes shimmer { to { background-position: -120% 0; } }

.empty-state {
  padding: 70px 24px;
  border: 1.5px dashed var(--line-strong);
  border-radius: var(--r-lg);
  background: var(--panel);
  text-align: center;
}

.empty-state h3 { margin: 0 0 8px; font-size: 22px; }
.empty-state p { color: var(--muted); margin-bottom: 22px; }
.empty-state .button {
  display: inline-flex;
  width: auto;
  margin: 4px;
}

.grid-footer { display: grid; place-items: center; gap: 8px; padding-top: 30px; }
#scrollSentinel { display: block; width: 100%; height: 1px; }

/* ---------- Brand cloud ---------- */

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

/* ---------- The brand wall ----------
   A printed brand wall, not a row of pills. Every mark sits on a white plate of
   one fixed height with the artwork scaled to a share of it, so a tall stacked
   lockup and a wide wordmark carry the same optical weight and nothing is
   recoloured, retraced or improvised to get there.
   The plate is what makes this work on the dark band: half these marks are
   near-black wordmarks that would disappear on ink, and giving them a plate is
   the only treatment that keeps them legible without touching the artwork.
   The count is the reason it is a wall rather than decoration: every plate is a
   filter, and the number says how much material sits behind it. */
.brand-cloud { display: block; }

/* The two shelves of the wall, in the owner's own words. A tier title is a
   quiet mono label, not a heading fighting the section's one h2. */
.brand-tier-title {
  margin: 26px 0 14px;
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--brand-lime);
}

.brand-tier-title:first-child { margin-top: 0; }

.brand-tier-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 12px;
}

/* The whole tile is one clean white plate. Half the marks arrive with their
   own white background baked into the file and half arrive transparent, and
   on a translucent tile the two render as two different designs. On solid
   white the difference disappears, which is the entire fix. */
.brand-cloud button {
  position: relative;
  display: grid;
  grid-template-rows: 1fr auto;
  gap: 10px;
  place-items: center;
  min-height: 96px;
  padding: 16px 14px;
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: var(--r-sm);
  background: #ffffff;
  transition:
    transform 260ms var(--spring),
    border-color 220ms var(--ease),
    background-color 220ms var(--ease);
}

.brand-cloud button:hover {
  transform: translateY(-3px);
  border-color: rgba(184, 231, 106, 0.7);
  background: #ffffff;
  box-shadow: 0 14px 34px -18px rgba(184, 231, 106, 0.5);
}

.brand-plate {
  display: grid;
  place-items: center;
  width: 100%;
  height: 62px;
  padding-inline: 14px;
  /* The tile itself is the white plate now; this inner box only centres. */
  background: none;
  box-shadow: none;
}

/* Scaled to a share of the plate rather than set at a fixed height, which is
   what stops a wide wordmark filling the plate edge to edge while a compact
   glyph floats in the middle of one. */
.brand-plate .brand-logo { height: 26px; max-width: 100%; width: auto; object-fit: contain; }
.brand-plate .brand-logo.sq { height: 28px; max-width: 34px; }
.brand-plate .brand-logo.stacked { height: 34px; max-width: 100%; }

/* Brands with no approved mark keep their name in type on the same plate, so a
   missing asset reads as a considered choice rather than a hole in the wall. */
/* A name set in type, at the same optical presence as the artwork beside it.
   Toshiba and Palo Alto Networks have no approved artwork in this repository
   and the egress policy blocks fetching any, so their plates set the name.
   At 13px that read as an apology; at this size, with this weight, it reads as
   a decision. The moment authentic files land in assets/brands they take over
   with no code change. */
/* Symbol plus company name, for the marks that are a bare glyph. The glyph
   is the manufacturer's, the name is a typeset label beside it. */
.brand-lockup {
  display: inline-flex;
  align-items: center;
  gap: 10px;
}

.brand-lockup-name {
  font-size: 19px;
  font-weight: 700;
  letter-spacing: 0;
  color: #1d1d1f;
  white-space: nowrap;
}

/* Matched to the owner's reference lockups. TP-Link sets its name lowercase
   in near-black beside the teal symbol; Ubiquiti stacks the U over UBIQUITI
   in the brand blue with the wide tracking its wordmark carries. */
.brand-lockup { max-width: 100%; }

.brand-lockup.is-tplink .brand-lockup-name {
  font-size: 17px;
  font-weight: 700;
  letter-spacing: 0;
}

/* Written at the depth of the generated size sheet, which would otherwise
   set the glyph to its solo 52px and push the name off the tile's edge:
   the owner's screenshot showed tp-link sliced mid-word. In a lockup the
   glyph shares the plate with the name, so it takes the smaller size. */
.brand-plate .brand-lockup .brand-logo[data-mark="tp-link.svg"] {
  width: auto;
  height: 30px;
}

.brand-plate .brand-lockup .brand-logo[data-mark="ubiquiti.svg"] {
  width: auto;
  height: 26px;
}

.brand-lockup.is-ubiquiti {
  flex-direction: column;
  gap: 3px;
}

.brand-lockup.is-ubiquiti .brand-logo { height: 26px; }

.brand-lockup.is-ubiquiti .brand-lockup-name {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.18em;
  color: #0559c9;
}

.brand-plate .brand-line {
  font-size: 17px;
  font-weight: 700;
  letter-spacing: 0;
  color: #1d1d1f;
  text-align: center;
}

.brand-cloud b {
  font-size: 11px;
  font-weight: 600;
  color: rgba(233, 240, 250, 0.62);
  font-variant-numeric: tabular-nums;
}

.industry-row span {
  min-height: 46px;
  display: inline-flex;
  align-items: center;
  padding: 0 22px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--panel);
  font-weight: 600;
  color: var(--ink-2);
}

/* ---------- The bulletin board, and the desk's list ----------
   A dark band, because this is the desk speaking rather than the catalogue
   listing: identity and voice belong on the field, part codes belong on paper.
   Everything inside is injected by bulletin.js, so every rule here has to hold
   for zero notes as well as twelve — the board is a grid that collapses to
   nothing rather than a layout that needs filling. */

.bulletin-band {
  margin: 0 var(--pad-x) clamp(56px, 6vw, 88px);
  padding: clamp(38px, 5vw, 68px) clamp(26px, 4vw, 58px);
  border-radius: clamp(24px, 3vw, 40px);
}

.bulletin-band .section-heading { max-width: 60ch; margin-bottom: 26px; }
.bulletin-band .section-heading h2 { letter-spacing: 0; }

/* The subject filters. A tab row rather than a select, because there are at
   most five and a reader should be able to see the whole shape of what the
   desk writes about without opening anything. */
.bulletin-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 24px;
}

.bulletin-filters button {
  font: inherit;
  font-size: 14px;
  font-weight: 500;
  padding: 8px 15px;
  border-radius: 999px;
  border: 1px solid rgba(184, 231, 106, 0.22);
  background: rgba(255, 255, 255, 0.05);
  color: rgba(233, 240, 250, 0.82);
  cursor: pointer;
  transition: background 180ms var(--ease), color 180ms var(--ease), border-color 180ms var(--ease);
}

.bulletin-filters button:hover {
  background: rgba(184, 231, 106, 0.12);
  color: #fff;
}

.bulletin-filters button[aria-selected="true"] {
  background: var(--brand-lime);
  border-color: var(--brand-lime);
  color: #0a1408;
  font-weight: 600;
}

/* `min(300px, 100%)` rather than a bare 300px. On a 390px phone the band's own
   margin and padding leave under 300px of content width, and a grid track with
   a hard minimum wider than its container does not shrink: it pushes the page
   sideways. Measured at 140px of horizontal overflow before this. */
.bulletin-board {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
  gap: 16px;
}

.bulletin-note {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 22px 22px 20px;
  border-radius: var(--r-md);
  border: 1px solid rgba(255, 255, 255, 0.09);
  background: rgba(255, 255, 255, 0.045);
  /* The lime edge marks the desk's own writing, the way the contact band's
     sweep marks the desk's own line. It is the only lime that is load-bearing
     here; everything else on the card is white at a set alpha. */
  border-top: 2px solid rgba(184, 231, 106, 0.55);
  transition: background 200ms var(--ease), border-color 200ms var(--ease);
}

.bulletin-note:hover { background: rgba(255, 255, 255, 0.07); }

.note-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}

.note-topic {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--brand-lime);
}

.note-head time {
  font-family: var(--mono);
  font-size: 11px;
  color: rgba(233, 240, 250, 0.55);
  font-variant-numeric: tabular-nums;
}

.bulletin-note h3 {
  margin: 0;
  font-size: 18px;
  line-height: 1.28;
  letter-spacing: 0;
  color: #fff;
  text-wrap: balance;
}

.bulletin-band .note-summary {
  margin: 0;
  font-size: 15px;
  line-height: 1.55;
  color: rgba(233, 240, 250, 0.78);
}

.bulletin-note details { margin-top: auto; padding-top: 4px; }

.bulletin-note summary {
  font-size: 14px;
  font-weight: 600;
  color: var(--brand-lime);
  cursor: pointer;
  list-style: none;
  width: fit-content;
  padding: 4px 0;
}

.bulletin-note summary::-webkit-details-marker { display: none; }
.bulletin-note summary:hover { color: #fff; }

/* The control has to say what it will do next, not what it did. Two labels
   rather than one, because "Read the note" still sitting above an open note is
   a button that has stopped describing itself. */
.bulletin-note summary .s-open { display: none; }
.bulletin-note details[open] summary .s-shut { display: none; }
.bulletin-note details[open] summary .s-open { display: inline; }
.bulletin-note summary .s-shut::after { content: " \2193"; }
.bulletin-note summary .s-open::after { content: " \2191"; }

.bulletin-band .note-body p {
  margin: 0 0 10px;
  font-size: 15px;
  line-height: 1.62;
  color: rgba(233, 240, 250, 0.8);
}

.bulletin-band .note-source {
  margin: 6px 0 0;
  font-size: 13px;
  color: rgba(233, 240, 250, 0.5);
}

.bulletin-band .note-source a { color: rgba(184, 231, 106, 0.9); }

/* ---------- The mailing list ---------- */

.subscribe-card {
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
  gap: clamp(20px, 3vw, 44px);
  align-items: start;
  margin-top: 28px;
  padding: clamp(24px, 3vw, 36px);
  border-radius: var(--r-lg);
  border: 1px solid rgba(184, 231, 106, 0.2);
  background:
    radial-gradient(120% 140% at 100% 0%, rgba(184, 231, 106, 0.09), transparent 62%),
    rgba(255, 255, 255, 0.04);
}

.subscribe-copy h3 {
  margin: 0 0 8px;
  font-size: clamp(20px, 2.2vw, 25px);
  letter-spacing: 0;
  color: #fff;
}

.bulletin-band .subscribe-copy p {
  margin: 0;
  font-size: 15px;
  line-height: 1.6;
  color: rgba(233, 240, 250, 0.7);
}

.subscribe-form { display: flex; flex-direction: column; gap: 14px; }

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

.subscribe-row input {
  flex: 1 1 220px;
  min-width: 0;
  font: inherit;
  font-size: 15px;
  padding: 12px 15px;
  border-radius: var(--r-sm);
  border: 1px solid rgba(255, 255, 255, 0.2);
  background: rgba(255, 255, 255, 0.07);
  color: #fff;
}

.subscribe-row input::placeholder { color: rgba(233, 240, 250, 0.4); }
.subscribe-row input:focus-visible {
  outline: 2px solid var(--brand-lime);
  outline-offset: 2px;
  border-color: transparent;
}

.subscribe-topics {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 16px;
  margin: 0;
  padding: 0;
  border: 0;
}

.subscribe-topics legend {
  padding: 0 0 8px;
  font-size: 13px;
  color: rgba(233, 240, 250, 0.55);
}

/* The label is the tap target, not the 16px box inside it, so it carries the
   height a thumb needs. */
.subscribe-topics label {
  display: flex;
  align-items: center;
  min-height: 32px;
  gap: 7px;
  font-size: 14px;
  color: rgba(233, 240, 250, 0.82);
  cursor: pointer;
}

.subscribe-topics input { accent-color: var(--brand-lime-deep); width: 16px; height: 16px; }

.bulletin-band .subscribe-status {
  margin: 0;
  min-height: 20px;
  font-size: 14px;
  color: rgba(184, 231, 106, 0.92);
}

.bulletin-band .subscribe-status a { color: var(--brand-lime); font-weight: 600; }

@media (max-width: 720px) {
  .subscribe-card { grid-template-columns: 1fr; }
}

/* ---------- One note, on its own page ----------
   Served by the worker at /bulletin/<slug>, so this is the only part of the
   site whose markup lives in TypeScript rather than in index.html. It gets a
   single column and nothing else: a reader who arrived from a search result
   wants the note, and every other thing on the page is a chance to lose them
   before the first paragraph. */

body.note-page {
  background:
    radial-gradient(120% 80% at 80% 0%, rgba(184, 231, 106, 0.10), transparent 58%),
    radial-gradient(90% 70% at 5% 100%, rgba(27, 95, 217, 0.13), transparent 60%),
    #050b16;
  color: #fff;
  min-height: 100vh;
  /* iOS Safari's 100vh includes the retracted toolbar, so the page jumped on
     scroll. dvh tracks the visible viewport; the vh line stays as the
     fallback for browsers without it. */
  min-height: 100dvh;
}

.note-sheet {
  max-width: 68ch;
  margin: 0 auto;
  padding: clamp(28px, 6vw, 72px) clamp(20px, 5vw, 32px) clamp(56px, 8vw, 96px);
}

.note-back {
  display: inline-block;
  margin-bottom: clamp(24px, 4vw, 40px);
  font-size: 14px;
  font-weight: 600;
  color: var(--brand-lime);
}

.note-back::before { content: "\2190  "; }
.note-back:hover { color: #fff; }

.note-kicker {
  margin: 0 0 12px;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--brand-lime);
}

/* The subject is a link back to the board, filtered to it. Somebody who liked
   this note wants the others on the same thing, and that is one click rather
   than a scroll and a guess. */
.note-kicker a { color: inherit; }
.note-kicker a:hover { color: #fff; }

.note-kicker time {
  margin-left: 10px;
  color: rgba(233, 240, 250, 0.5);
  letter-spacing: 0.1em;
}

.note-sheet h1 {
  margin: 0 0 16px;
  font-size: clamp(30px, 5.4vw, 50px);
  line-height: 1.08;
  letter-spacing: 0;
  text-wrap: balance;
}

/* The standfirst. Larger than the body and a shade brighter, because it is the
   sentence that decides whether the rest gets read. */
.note-stand {
  margin: 0 0 clamp(26px, 4vw, 40px);
  padding-bottom: clamp(22px, 3vw, 32px);
  border-bottom: 1px solid rgba(184, 231, 106, 0.22);
  font-size: clamp(17px, 2.1vw, 20px);
  line-height: 1.5;
  color: rgba(240, 246, 255, 0.94);
}

.note-prose p {
  margin: 0 0 20px;
  font-size: clamp(16px, 1.9vw, 17.5px);
  line-height: 1.72;
  color: rgba(233, 240, 250, 0.86);
}

.note-credit {
  margin: 28px 0 0;
  font-size: 13px;
  color: rgba(233, 240, 250, 0.5);
}

.note-credit a { color: rgba(184, 231, 106, 0.9); }

.note-cta {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  margin-top: clamp(40px, 6vw, 64px);
  padding: clamp(22px, 3vw, 30px);
  border-radius: var(--r-lg);
  border: 1px solid rgba(184, 231, 106, 0.2);
  background: rgba(255, 255, 255, 0.04);
}

.note-cta p {
  flex: 1 1 240px;
  margin: 0;
  font-size: 15px;
  line-height: 1.55;
  color: rgba(233, 240, 250, 0.82);
}

/* The two buttons stay a pair. Left loose in the parent flex they wrap
   independently, which puts one beside the sentence and drops the other onto
   its own line looking like an afterthought. */
.note-cta-actions {
  display: flex;
  flex: 0 1 auto;
  flex-wrap: wrap;
  gap: 10px;
}

.note-page .button.ghost {
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  border-color: transparent;
}

.note-page .button.ghost:hover { background: rgba(255, 255, 255, 0.18); }

/* The honeypot.
   Deliberately NOT `display: none` and NOT `type="hidden"`: both are the first
   thing a form-filling bot learns to skip. This is off-canvas and unreachable
   by keyboard, so a person never meets it and a script that fills every input
   it can find fills this one. `aria-hidden` on the wrapper keeps it away from
   screen readers, which is the one audience that would otherwise be caught by
   its own field. */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* ---------- a saved list, as a page ----------
   Wider than a note, because this is a table somebody reads across rather than
   prose they read down. */

.list-sheet { max-width: 820px; }

.list-code {
  font-family: var(--mono);
  letter-spacing: 0.14em;
  color: rgba(233, 240, 250, 0.62);
}

.list-table {
  width: 100%;
  margin: clamp(24px, 4vw, 36px) 0 0;
  border-collapse: collapse;
  font-size: 15px;
}

.list-table th {
  padding: 0 12px 10px;
  text-align: left;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(233, 240, 250, 0.5);
  border-bottom: 1px solid rgba(184, 231, 106, 0.24);
}

.list-table td {
  padding: 13px 12px;
  vertical-align: top;
  line-height: 1.45;
  color: rgba(233, 240, 250, 0.88);
  border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}

/* Quantities line up as a column of numbers, not as text that happens to be
   numeric. */
.list-qty {
  width: 4.5em;
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  color: #fff;
}

.list-sku {
  /* Wide enough for a real part code. The catalogue holds names like
     DS-ICS-V2-1292-ROTARY-SWITCH-PANEL-MOUNT, and at 12em those broke across
     four lines while the description column sat half empty beside them. */
  width: 17em;
  font-family: var(--mono);
  font-size: 14px;
  color: var(--brand-lime);
  word-break: break-word;
}

.list-note {
  margin: 22px 0 0;
  padding: 14px 16px;
  border-radius: var(--r-md);
  background: rgba(255, 255, 255, 0.045);
  font-size: 15px;
  line-height: 1.6;
  color: rgba(233, 240, 250, 0.82);
}

.list-note strong { color: #fff; }

@media (max-width: 560px) {
  /* Part code above description rather than beside it: at this width three
     columns give the description about twelve characters. */
  .list-table, .list-table tbody, .list-table tr, .list-table td { display: block; width: auto; }
  .list-table thead { display: none; }
  .list-table tr {
    padding: 12px 0;
    border-bottom: 1px solid rgba(255, 255, 255, 0.07);
  }
  .list-table td { padding: 2px 0; border: 0; }
  .list-qty::after { content: " x"; color: rgba(233, 240, 250, 0.5); }
  .list-qty { display: inline-block; }
  .list-sku { display: inline-block; margin-left: 8px; }
}

/* The list, on the note itself. A reader who arrived from a search result and
   read to the end is the best subscriber the desk will get, and asking them
   here costs nothing they were going to do anyway. */
.note-join {
  margin-top: clamp(40px, 6vw, 64px);
  padding: clamp(24px, 3.5vw, 34px);
  border-radius: var(--r-lg);
  border: 1px solid rgba(184, 231, 106, 0.24);
  background:
    radial-gradient(130% 150% at 100% 0%, rgba(184, 231, 106, 0.1), transparent 62%),
    rgba(255, 255, 255, 0.04);
}

.note-join h2 {
  margin: 0 0 8px;
  font-size: clamp(20px, 2.4vw, 26px);
  letter-spacing: 0;
  color: #fff;
}

.note-join p {
  margin: 0 0 18px;
  max-width: 52ch;
  font-size: 15px;
  line-height: 1.6;
  color: rgba(233, 240, 250, 0.72);
}

.note-join-row { display: flex; flex-wrap: wrap; gap: 10px; }

.note-join-row input {
  flex: 1 1 220px;
  min-width: 0;
  font: inherit;
  font-size: 15px;
  padding: 12px 15px;
  border-radius: var(--r-sm);
  border: 1px solid rgba(255, 255, 255, 0.2);
  background: rgba(255, 255, 255, 0.07);
  color: #fff;
}

.note-join-row input::placeholder { color: rgba(233, 240, 250, 0.4); }

.note-join-row input:focus-visible {
  outline: 2px solid var(--brand-lime);
  outline-offset: 2px;
  border-color: transparent;
}

/* The two cards at the foot of a note are a pair, not two sections. The full
   margin belongs above the first one, marking the end of the reading. */
.note-join + .note-cta { margin-top: 14px; }

/* What to read next. Two, because a page that offers everything offers
   nothing, and this exists to keep somebody here rather than to be an index. */
.note-next {
  display: grid;
  gap: 10px;
  margin-top: clamp(36px, 5vw, 56px);
}

.note-next h2 {
  margin: 0 0 4px;
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: rgba(233, 240, 250, 0.5);
}

.note-next a {
  display: grid;
  gap: 5px;
  padding: 16px 18px;
  border-radius: var(--r-md);
  border: 1px solid rgba(255, 255, 255, 0.09);
  background: rgba(255, 255, 255, 0.035);
  transition: background 180ms var(--ease), border-color 180ms var(--ease);
}

.note-next a:hover {
  background: rgba(255, 255, 255, 0.07);
  border-color: rgba(184, 231, 106, 0.34);
}

.note-next-topic {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--brand-lime);
}

.note-next-title {
  font-size: 16px;
  font-weight: 600;
  line-height: 1.3;
  color: #fff;
}

.note-next-all {
  justify-items: start;
  font-size: 14px;
  font-weight: 600;
  color: var(--brand-lime);
  background: none;
  border: 0;
  padding: 6px 0 0;
}

.note-next-all:hover { background: none; color: #fff; }

/* The board's link out to a note's own page. Quieter than the open control
   next to it, because opening in place is what most readers want and the
   permalink is for the one who wants to send it to somebody. */
/* Padded to a thumb rather than sized to its text. At 12.5px the link was 18px
   tall, which is a link a phone user aims at and misses. The padding is inside
   the card's own flow so nothing moves on the desktop layout. */
.note-permalink {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  padding: 4px 0;
  font-size: 13px;
  color: rgba(233, 240, 250, 0.55);
  white-space: nowrap;
}

.note-permalink:hover { color: var(--brand-lime); }

/* Sits under the open control, not beside it. `details` already carries the
   `margin-top: auto` that pins the pair to the bottom of a card, and a second
   auto margin here would split the free space between them instead. */
.note-controls {
  display: flex;
  align-items: baseline;
  justify-content: flex-start;
  gap: 14px;
  padding-top: 8px;
  border-top: 1px solid rgba(255, 255, 255, 0.07);
  margin-top: 10px;
}

/* ---------- Contact band ---------- */

.contact-band {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 28px;
  margin: 0 var(--pad-x) clamp(56px, 6vw, 88px);
  padding: clamp(38px, 5vw, 68px);
  border-radius: clamp(24px, 3vw, 40px);
  /* The CommScope Connect hero gradient: deep navy with an electric bloom at
     the top edge. The one dark surface on the page now reads as the same
     command-centre material as the partner portal, rather than flat black. */
  background: var(--deep-grad, #101013);
  color: #fff;
  position: relative;
  overflow: hidden;
}

/* A single lime scan line drifts across the band's top edge — the portal's
   signature sweep, at the pace of an ambient effect rather than an alert. */
.contact-band::before {
  content: "";
  position: absolute;
  top: 0; left: 0;
  width: 40%;
  height: 1.5px;
  background: var(--sweep-grad);
  animation: band-sweep 7s var(--ease-confident, ease-in-out) infinite;
  pointer-events: none;
}

@keyframes band-sweep {
  0% { transform: translateX(-110%); opacity: 0; }
  18% { opacity: 1; }
  60% { opacity: 1; }
  100% { transform: translateX(280%); opacity: 0; }
}

.contact-band .eyebrow { color: #34d399; }
.contact-band h2 { max-width: 24ch; margin-bottom: 6px; letter-spacing: 0; }
.contact-band .sub { color: rgba(255, 255, 255, 0.55); }

.contact-actions { display: flex; flex-wrap: wrap; gap: 12px; }
.contact-band .button.ghost { background: rgba(255, 255, 255, 0.1); color: #fff; }
.contact-band .button.ghost:hover { background: rgba(255, 255, 255, 0.18); }

.site-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  padding: 24px var(--pad-x) 44px;
  border: 0;
  color: var(--faint);
  font-size: 13px;
}

.site-footer img { height: 22px; width: auto; opacity: 0.85; }

/* ---------- RFQ drawer ---------- */

.rfq-drawer {
  position: fixed;
  inset: 0 0 0 auto;
  z-index: var(--z-drawer);
  width: min(430px, 100vw);
  display: none;
  gap: 14px;
  padding: 22px;
  /* Safety net for short viewports: the list gives way first, and only if the
     form itself still does not fit does the whole panel scroll. */
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Nearly opaque: the panel is a working surface with form fields in it, and at
     0.92 the brand marquee behind it showed through as grey smudges. */
  background: rgba(251, 251, 253, 0.975);
  border-left: 1px solid var(--line);
  box-shadow: -24px 0 70px rgba(0, 0, 0, 0.14);
  transform: translateX(105%);
  transition: transform 420ms var(--spring);
}

/* Flex, not the grid template: with a 1fr list row a single line item left a
   ~250px hole above the tools. As a flex column the list sizes to its content
   and scrolls only once it runs out of room. */
.rfq-drawer.is-open { display: flex; flex-direction: column; transform: translateX(0); }

.drawer-header button, .dialog-close {
  width: 38px;
  min-height: 38px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.06);
  font-size: 19px;
  line-height: 1;
}

.rfq-items {
  flex: 0 1 auto;
  min-height: 0;
  overflow: auto;
  overscroll-behavior: contain;
  display: grid;
  align-content: start;
  gap: 10px;
  padding-right: 2px;
}

/* Everything below the list keeps its natural height; the list is the only part
   that gives way when the drawer runs short. */
.drawer-header,
.drawer-tools,
.rfq-buyer,
.rfq-drawer textarea,
.drawer-actions { flex: 0 0 auto; }

.rfq-summary {
  display: grid;
  gap: 3px;
  padding: 12px 13px;
  border: 1px solid rgba(13, 159, 110, 0.18);
  border-radius: var(--r-md);
  background: rgba(13, 159, 110, 0.07);
}

.rfq-summary strong {
  font-size: 14px;
  color: var(--ink);
}

.rfq-summary span {
  color: var(--muted);
  font-size: 13px;
}

.rfq-item {
  display: grid;
  grid-template-columns: 52px 1fr auto;
  gap: 12px;
  align-items: center;
  padding: 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: #fff;
  animation: card-in 300ms var(--ease) both;
}

.rfq-item img, .rfq-thumb {
  width: 52px; height: 52px;
  border-radius: 10px;
  background: var(--plate);
}

.rfq-item img { object-fit: contain; }
.rfq-thumb { display: grid; place-items: center; color: var(--faint); font-size: 10px; font-weight: 600; }

.rfq-item h3 { margin: 0; font-size: 14px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
.rfq-item p { margin: 3px 0 0; color: var(--muted); font-size: 11px; font-family: var(--mono); }

.qty-control {
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 3px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.05);
}

.qty-control button {
  width: 26px; height: 26px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: transparent;
  font-size: 15px;
  transition: background 140ms var(--ease);
}

.qty-control button:hover { background: #fff; box-shadow: var(--shadow-sm); }

.qty-control input {
  width: 32px;
  border: 0;
  background: transparent;
  text-align: center;
  font-size: 14px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  outline: none;
  -moz-appearance: textfield;
}

.qty-control input::-webkit-outer-spin-button,
.qty-control input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

.drawer-tools { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.drawer-tools button { min-height: 38px; padding: 0 8px; font-size: 13px; }

.rfq-drawer textarea { min-height: 76px; padding: 13px; resize: vertical; }
.drawer-actions { display: grid; grid-template-columns: 1fr; gap: 10px; }
.drawer-actions .button { width: 100%; }

.drawer-note {
  margin: 0;
  color: var(--muted);
  font-size: 13px;
  text-align: center;
}

.drawer-note a { color: var(--green-deep); font-weight: 600; }

/* The written route's reply line. It carries the reference the sender will
   quote if they ring, so it is set in mono and given its own height whether or
   not it has anything to say: a line that appears from nowhere pushes the call
   button out from under a thumb that was already reaching for it. */
.drawer-status {
  margin: 0;
  min-height: 18px;
  font-family: var(--mono);
  font-size: 13px;
  line-height: 1.4;
  text-align: center;
  color: var(--green-deep);
}

.drawer-status a { color: var(--green-deep); font-weight: 600; }

.rfq-optin {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  padding: 2px 2px 0;
  font-size: 13px;
  line-height: 1.45;
  color: var(--ink-2);
  cursor: pointer;
}

.rfq-optin input {
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  margin-top: 1px;
  accent-color: var(--green);
}

/* Buyer details: a quote needs a name, a company, and a delivery city, so the
   desk can price and ship without a round trip. */
.rfq-buyer {
  display: grid;
  gap: 10px;
  padding: 14px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: rgba(13, 159, 110, 0.04);
}

.rfq-buyer-head {
  margin: 0;
  color: var(--ink-2);
  font-size: 13px;
  font-weight: 600;
}

.rfq-buyer-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

.rfq-buyer-grid label {
  display: grid;
  gap: 5px;
  min-width: 0;
}

.rfq-buyer-grid span {
  color: var(--muted);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.rfq-buyer-grid input {
  width: 100%;
  min-height: 38px;
  padding: 0 11px;
  border: 1px solid var(--line-strong);
  border-radius: 11px;
  background: #fff;
  color: var(--ink);
  font: inherit;
  font-size: 14px;
}

.rfq-buyer-grid input:focus {
  outline: none;
  border-color: var(--green);
  box-shadow: 0 0 0 3px rgba(13, 159, 110, 0.14);
}

/* ---------- Product dialog ---------- */

/* The sheet is taller than the viewport on almost every product, and with a
   single non-scrolling box the user agent's own max-height simply clipped
   everything past the fold — technical specs, OEM links and related products
   were all unreachable. The content scrolls inside .dialog-scroll instead, so
   the rounded corners still clip and the close button stays put. */
.product-dialog {
  width: min(980px, calc(100vw - 28px));
  max-height: min(920px, calc(100vh - 32px));
  max-height: min(920px, calc(100dvh - 32px));
  display: flex;
  flex-direction: column;
  position: relative;
  padding: 0;
  border: 0;
  border-radius: var(--r-lg);
  background: #fff;
  color: var(--ink);
  box-shadow: var(--shadow-lg);
  overflow: hidden;
}

.dialog-scroll {
  min-height: 0;
  flex: 1 1 auto;
  overflow: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}

.product-dialog[open] { animation: dialog-in 380ms var(--spring); }

@keyframes dialog-in {
  from { opacity: 0; transform: translateY(22px) scale(0.97); }
}

.product-dialog::backdrop {
  background: rgba(0, 0, 0, 0.55);
}

body.dialog-fallback-open { overflow: hidden; }

.product-dialog.is-fallback-open {
  position: fixed;
  inset: 50% auto auto 50%;
  transform: translate(-50%, -50%);
  max-height: calc(100vh - 28px);
  max-height: calc(100dvh - 28px);
  z-index: var(--z-dialog);
}

body.dialog-fallback-open::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-dialog) - 1);
  background: rgba(0, 0, 0, 0.55);
}

/* Anchored to the dialog, which no longer scrolls, so it stays reachable however
   far down the sheet the reader is. */
.dialog-close {
  position: absolute;
  right: 16px; top: 16px;
  z-index: 5;
  background: rgba(255, 255, 255, 0.88);
  backdrop-filter: blur(10px);
  box-shadow: var(--shadow-sm);
}

.detail-grid { display: grid; grid-template-columns: minmax(280px, 430px) minmax(0, 1fr); }
.detail-grid.no-photo-detail {
  grid-template-columns: minmax(0, 1fr);
}

.detail-media {
  min-height: 460px;
  display: grid;
  place-items: start center;
  padding: 34px;
  background: var(--plate);
}

/* Sticky so the part stays in view while the specs scroll past it — on a
   procurement sheet you read the table against the photo. */
.detail-media img {
  position: sticky;
  top: 0;
  max-width: 100%;
  max-height: 380px;
  object-fit: contain;
  align-self: start;
}
.detail-media.pending {
  min-height: 300px;
  padding: 24px;
  background: linear-gradient(180deg, #fcfcfd, #f4f6f8);
}

.detail-media .no-photo-plate .glyph { width: 64px; height: 64px; }

.no-photo-detail .detail-media {
  border-bottom: 1px solid var(--line);
}

.detail-copy { padding: 44px 38px 36px; }

.detail-brand-logo {
  display: block;
  height: 24px;
  width: auto;
  max-width: 150px;
  object-fit: contain;
  object-position: left center;
  margin-bottom: 16px;
}

.detail-brand-logo.subbrand-logo {
  height: 46px;
  max-width: 260px;
  margin-bottom: 18px;
}

.detail-brand-logo.subbrand-logo.systimax { height: 54px; }

.marquee-subbrand {
  height: 32px;
  min-width: 150px;
  display: inline-flex;
  flex-direction: column;
  justify-content: center;
  flex: 0 0 auto;
  margin: 0 24px;
  line-height: 1;
  text-transform: uppercase;
}

.marquee-subbrand b {
  font-size: 20px;
  font-weight: 700;
  letter-spacing: 0;
}

.marquee-subbrand small {
  color: var(--muted);
  font-size: 8px;
  font-weight: 700;
  letter-spacing: 0.06em;
}

.marquee-subbrand.systimax b { color: #f05a28; }
.marquee-subbrand.netconnect b { color: #1b365d; }

.marquee-subbrand-logo {
  height: 38px;
  width: auto;
  max-width: 210px;
  object-fit: contain;
  mix-blend-mode: multiply;
  filter: grayscale(1) opacity(0.78);
  flex: 0 0 auto;
  margin: 0 24px;
}

.detail-copy h2 { margin-bottom: 10px; font-size: clamp(26px, 2.4vw, 34px); }
.detail-copy > p { color: var(--muted); }

.detail-list { display: grid; gap: 0; margin: 26px 0; }

.detail-list div {
  display: grid;
  grid-template-columns: 130px 1fr;
  gap: 12px;
  padding: 12px 0;
  border-bottom: 1px solid var(--line);
}

.detail-list div:first-child { border-top: 1px solid var(--line); }
.detail-list dt { color: var(--muted); font-size: 14px; font-weight: 600; }
.detail-list dd { margin: 0; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }

.related-products {
  margin-top: 28px;
  padding-top: 20px;
  border-top: 1px solid var(--line);
}

.related-products h3 {
  margin: 0 0 12px;
  font-size: 15px;
  color: var(--ink-2);
}

.related-products > div {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
}

.related-products button {
  min-width: 0;
  display: grid;
  grid-template-columns: 42px minmax(0, 1fr);
  gap: 10px;
  align-items: center;
  padding: 9px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: #fff;
  text-align: left;
  transition: border-color 180ms var(--ease), transform 180ms var(--ease), box-shadow 180ms var(--ease);
}

.related-products button:hover {
  transform: translateY(-1px);
  border-color: rgba(13, 159, 110, 0.28);
  box-shadow: var(--shadow-sm);
}

.related-products .rfq-thumb,
.related-products img {
  width: 42px;
  height: 42px;
  border-radius: 10px;
  object-fit: contain;
}

.related-products span {
  min-width: 0;
  display: grid;
  gap: 2px;
}

.related-products b,
.related-products small {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.related-products b {
  font-family: var(--mono);
  font-size: 11px;
}

.related-products small {
  color: var(--muted);
  font-size: 11px;
}

/* ---------- Floating WhatsApp + toasts ---------- */

/* Bottom-right, not bottom-left: on the left it sat on top of the hero copy and
   the phone link at short viewport heights. */
/* Landmark wrapper only. Its single child is fixed-positioned, so the wrapper
   is already a zero-height block and needs no layout rules. Deliberately not
   `display: contents`, which can drop the element, and with it the landmark,
   out of the accessibility tree. */
.float-actions { margin: 0; padding: 0; }

.whatsapp-float {
  position: fixed;
  right: max(20px, env(safe-area-inset-right, 0px));
  bottom: calc(20px + env(safe-area-inset-bottom, 0px));
  z-index: var(--z-float);
  /* Icon-only. The pill with its label sat on top of the last column's
     card actions at common scroll positions; a 58px circle covers half the
     area and reads identically, because the glyph is the whole message. */
  width: 58px;
  height: 58px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border-radius: 999px;
  /* WhatsApp's own #25d366 puts white text at 1.98:1, which is unreadable by
     any measure. This is our call-to-action rather than WhatsApp's product, so
     it takes the brand green that the header button already uses and clears
     5.3:1. The glyph still says which app it opens. */
  background: var(--green-deep);
  color: #fff;
  font-weight: 600;
  box-shadow: 0 10px 30px rgba(8, 122, 84, 0.34);
  transition: transform 260ms var(--spring), box-shadow 260ms var(--ease);
}

.whatsapp-float:hover { transform: translateY(-3px) scale(1.03); box-shadow: 0 16px 42px rgba(8, 122, 84, 0.44); }
.whatsapp-float svg { width: 21px; height: 21px; flex: 0 0 auto; }

.toast-stack {
  position: fixed;
  left: 50%; bottom: 28px;
  z-index: var(--z-toast);
  display: grid;
  gap: 8px;
  transform: translateX(-50%);
  pointer-events: none;
}

.toast {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 20px;
  border-radius: 999px;
  background: rgba(29, 29, 31, 0.92);
  color: #fff;
  font-size: 14px;
  font-weight: 600;
  box-shadow: var(--shadow-lg);
  animation: toast-in 340ms var(--spring);
}

.toast::before {
  content: "";
  width: 7px; height: 7px;
  border-radius: 50%;
  background: #34d399;
}

.toast.out { animation: toast-out 260ms var(--ease) forwards; }

@keyframes toast-in { from { opacity: 0; transform: translateY(16px) scale(0.96); } }
@keyframes toast-out { to { opacity: 0; transform: translateY(10px); } }


.suggest .s-thumb img { object-fit: contain; }

/* ---------- Responsive ---------- */

@media (max-width: 1240px) {
  .split { grid-template-columns: 262px minmax(0, 1fr); }
  .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .solution-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .category-spotlight { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* Collapse to the menu button well before the links run out of room. */
@media (max-width: 1180px) {
  .nav-shell { grid-template-columns: auto 1fr; row-gap: 10px; }

  .nav-links {
    display: none;
    order: 3;
    grid-column: 1 / -1;
    justify-content: flex-start;
    flex-wrap: wrap;
    padding-bottom: 8px;
  }

  .nav-links.is-open { display: flex; animation: pop-in 240ms var(--spring); }
  .mobile-menu { display: inline-flex; order: 2; justify-self: end; }
  .nav-actions { order: 4; grid-column: 1 / -1; }
  .header-search { flex: 1; width: auto; }

  .hero { grid-template-columns: 1fr; }
  /* Once the hero stacks, the panel must stack with it. Keeping the two-column
     split here parked the four launch cards in the left half of the band and
     left the right half empty, which is how the 834px iPad rendered until the
     first proper tablet review caught it. */
  .hero-panel { grid-template-columns: 1fr; }
  .solution-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .category-spotlight { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (max-width: 900px) {
  .split { grid-template-columns: 1fr; }
  .boq-panel { grid-template-columns: 1fr; }

  /* Filters collapse behind a toggle so products, not four dropdowns, are the
     first thing on screen. */
  .filters { position: static; padding: 14px 16px; gap: 0; }
  .filter-header .filter-toggle { display: inline-flex; }
  .filter-body { display: none; }
  .filters.is-open .filter-body {
    display: grid;
    gap: 16px;
    padding-top: 16px;
    animation: pop-in 220ms var(--spring);
  }
  .detail-grid { grid-template-columns: 1fr; }
  .detail-media { min-height: 300px; }
  .detail-copy { padding: 28px 22px; }
  /* Icon-only circle on small screens, so it covers as little of the page as a
     floating action can. */
  .whatsapp-float span { display: none; }
  .whatsapp-float { width: 52px; padding: 0; }
}

@media (max-width: 620px) {
  .logo-marquee { display: none; }
  .search-kbd { display: none; }

  /* Header compression. Two rows are unavoidable at this width, but the stack
     was 145px — a sixth of an iPhone viewport — before the page even started.
     Trimming the strip, the row padding and the wordmark brings it under 120px. */
  .top-strip {
    justify-content: center;
    gap: 12px;
    min-height: 28px;
    padding: 3px var(--pad-x);
    font-size: 12px;
  }
  .top-strip span:not(.pulse-dot) { display: none; }
  .nav-shell { min-height: 0; row-gap: 8px; padding: 8px var(--pad-x) 10px; }
  /* Narrower on a phone, and the height follows the mark's own 1631:281 rather
     than being set independently: a mismatched pair is what squashed it. */
  .brand-mark { flex-basis: 140px; width: 140px; min-width: 140px; height: 24px; }
  .brand-mark img { width: 140px; height: 24px; }
  .mobile-menu { min-height: 38px; }
  .header-search input { height: 38px; }

  /* Search and RFQ share one row, and the header's WhatsApp button is dropped:
     the top strip and the floating button already offer it, and the extra row
     cost ~50px of a 844px-tall viewport. */
  .nav-actions { flex-wrap: nowrap; gap: 8px; }
  .nav-actions .header-search { flex: 1 1 auto; min-width: 0; }
  .nav-actions .button.primary { display: none; }
  .nav-actions .cart { flex: 0 0 auto; min-height: 38px; }

  /* The compressed mobile header measures ~125px, so anchored sections need that
     much clearance or they scroll in underneath it. */
  html { scroll-padding-top: 138px; }

  .product-grid, .solution-grid, .category-spotlight { grid-template-columns: 1fr; }
  .category-spotlight button { min-height: 112px; }
  .product-grid { gap: 18px; }
  .product-card {
    border-radius: 24px;
    box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
  }
  .image-frame {
    height: 210px;
    padding: 18px;
  }
  .image-frame.pending { height: 210px; }
  .no-photo-plate .glyph { width: 40px; height: 40px; }
  .photo-badge {
    left: 14px;
    top: 14px;
    max-width: calc(100% - 28px);
    white-space: nowrap;
  }
  .product-body {
    padding: 18px 20px 14px;
    gap: 10px;
  }
  .brand-row {
    min-height: 42px;
    align-items: flex-start;
  }
  .brand-logo {
    height: 22px;
    max-width: 148px;
  }
  .brand-logo.sq { height: 24px; }
  .brand-line {
    max-width: 180px;
    font-size: 12px;
    overflow-wrap: anywhere;
  }
  .product-card h3 {
    font-size: 17px;
    line-height: 1.25;
    letter-spacing: 0;
  }
  .product-meta { font-size: 13px; }
  .card-actions {
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    padding: 0 20px calc(20px + env(safe-area-inset-bottom, 0px));
  }
  /* Primary takes the full width; the two quiet actions share the next row. */
  .card-actions .primary-action { grid-column: 1 / -1; }
  .card-actions button,
  .card-actions a {
    min-height: 46px;
    padding: 0 12px;
    font-size: 15px;
  }
  .product-dialog {
    width: 100vw;
    max-width: 100vw;
    height: 100dvh;
    max-height: 100dvh;
    margin: 0;
    border-radius: 0;
  }
  .detail-media {
    min-height: 260px;
    padding: 28px 20px;
  }
  .detail-copy {
    padding: 26px 20px calc(34px + env(safe-area-inset-bottom, 0px));
  }
  .detail-list div {
    grid-template-columns: 96px 1fr;
  }
  .detail-copy .hero-actions {
    display: grid;
    grid-template-columns: 1fr;
  }
  /* The submit label costs ~65px of a 347px field, which is enough to truncate
     the placeholder. The arrow keeps the affordance; the button carries an
     aria-label so the accessible name survives. */
  .hero-search { padding-left: 16px; gap: 8px; }
  .hero-search input { font-size: 16px; }
  .hs-go { padding: 0; width: 46px; }
  .hs-go span { display: none; }

  .hero { padding-top: 44px; }
  .hero-panel { grid-template-columns: 1fr; }
  /* Never a fixed height. The gallery is a two by two grid of tiles whose
     labels wrap to more lines the narrower the phone gets, so 240px was a
     ceiling the content grew straight through: the tiles overflowed the box
     and landed on top of the three claims underneath, which is what the owner
     photographed. The grid sizes to what is in it. */
  .hero-gallery { height: auto; min-height: 240px; }
  .hero-copy .lede { font-size: 17px; }

  .results-toolbar, .contact-band { align-items: flex-start; flex-direction: column; }
  .toolbar-actions { width: 100%; }
  .toolbar-actions .sort-wrap { flex: 1; }
  .toolbar-actions .sort-wrap select { width: 100%; }

  .drawer-tools { grid-template-columns: 1fr 1fr; }
  h1 { font-size: 34px; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    /* Delays are zeroed too, not just durations. Staggered entrances hold their
       element hidden through the delay (animation-fill-mode: backwards), so a
       zero-duration animation with a 300ms delay is still 300ms of blank grid —
       the exact thing this query exists to prevent. */
    animation-delay: 0ms !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }
  .reveal { opacity: 1; transform: none; }
  .marquee-track { animation: none; }
  /* Stated rather than left to the blanket rule above. That one would run each
     field layer once at 0.01ms and leave it parked wherever its last keyframe
     put it, which for the spark is off the right edge. Parking them at rest
     keeps the composition the designed one, and the field is legible as a
     still: it was drawn to be. */
  .field { animation: none !important; transform: none !important; }
}

.view-pill {
  flex: 0 0 auto;
  min-height: 28px;
  padding: 0 12px;
  border: 0;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.05);
  color: var(--ink-2);
  font-size: 12px;
  font-weight: 600;
  transition: background 160ms var(--ease);
}
.view-pill:hover { background: rgba(0, 0, 0, 0.1); }

.partner-hub {
  position: relative;
  overflow: hidden;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  background:
    linear-gradient(135deg, rgba(9, 14, 22, 0.94), rgba(18, 27, 38, 0.9)),
    radial-gradient(circle at 80% 20%, rgba(37, 211, 102, 0.18), transparent 32%);
  color: #fff;
}

/* ============================================================
   Premium motion + interaction pass
   ============================================================ */

:root {
  --blue: #0a84ff;
  --violet: #6e5cff;
  --gold: #c49338;
  --premium-shadow: 0 18px 46px rgba(18, 24, 34, 0.11), 0 2px 8px rgba(18, 24, 34, 0.05);
}

/* The line that used to be here read:
     h1, h2, h3, .brand-line, .line-label, .eyebrow { letter-spacing: 0; }
   It arrived with the premium-motion pass and quietly flattened the type
   system from below. `.eyebrow` here ties the tracking rules above on
   specificity, so being 4,000 lines later it won: the mono eyebrows were
   designed at 0.22em and 0.28em of tracking and have been rendering at none.
   The display headings only survived because their selectors carry a class.
   A reset this broad has no job a token cannot do better; deleted rather than
   scoped, so the type system has one owner again. */

.aurora {
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(245, 245, 247, 0.92) 46%, #f5f5f7),
    radial-gradient(1200px 520px at 50% -12%, rgba(13, 159, 110, 0.16), transparent 62%),
    linear-gradient(116deg, rgba(10, 132, 255, 0.07), transparent 38%, rgba(196, 147, 56, 0.08) 78%, transparent);
}

.grid-veil {
  display: block;
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(rgba(29, 29, 31, 0.035) 1px, transparent 1px),
    linear-gradient(90deg, rgba(29, 29, 31, 0.035) 1px, transparent 1px);
  background-size: 76px 76px;
  mask-image: linear-gradient(180deg, rgba(0,0,0,0.56), transparent 58%);
}

.grain {
  display: block;
  position: absolute;
  inset: 0;
  opacity: 0.05;
  background-image:
    repeating-linear-gradient(0deg, rgba(0,0,0,0.28) 0 1px, transparent 1px 3px);
  mix-blend-mode: multiply;
}

.site-header {
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.55) inset;
}

.nav-links a {
  position: relative;
}

.nav-links a::after {
  content: "";
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: 4px;
  height: 2px;
  border-radius: 999px;
  background: var(--accent-grad);
  opacity: 0;
  transform: scaleX(0.4);
  transition: opacity 180ms var(--ease), transform 260ms var(--spring);
}

.nav-links a:hover::after {
  opacity: 1;
  transform: scaleX(1);
}

/* The hidden state for `.reveal` lives with the rest of the reveal rules near
   the top of this file, gated behind `html.js`. It used to be repeated here,
   ungated and later in the cascade, which meant it won: with JavaScript
   disabled every section heading on the page stayed at opacity 0 forever,
   because the class that reveals them is only ever added by an observer. */

/* Hero entrance. Runs on load rather than on scroll — the hero is already in
   view — and staggers off --step so the eye lands on the headline, then the
   search field. Short and shallow on purpose: 520ms, 14px, no bounce. */
.reveal-in {
  animation: reveal-in 520ms var(--ease) both;
  animation-delay: calc(var(--step, 0) * 70ms);
}

@keyframes reveal-in {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: none; }
}

.hero {
  position: relative;
}

/* A 1px teal-and-lime rule used to run across the foot of the hero. It was
   written when the page was a stack of bands and its job was to mark where one
   surface ended and the next began. There are no bands now — one sky runs the
   whole document — so all it did was draw the ruled line everything else was
   built to avoid, at the very top of the page where it is seen first. Measured
   at the join it lifted a single pixel row from 62 to 99. Gone.
   (`.hero::before` is still used, as a scrim over the whole hero, in the phone
   block further down. It could never work while this rule existed: an absolute
   box with top and bottom both zero and an explicit `height: 1px` obeys the
   height, so the phone scrim was one pixel tall.) */

.hero-copy {
  position: relative;
}

.hero-copy::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -30px;
  width: min(520px, 76vw);
  height: 1px;
  background: linear-gradient(90deg, rgba(159, 232, 112, 0.75), transparent);
  transform-origin: left center;
  animation: line-sweep 3.6s var(--ease) infinite;
}

@keyframes line-sweep {
  0%, 38%, 100% { transform: scaleX(0.2); opacity: 0.18; }
  58% { transform: scaleX(1); opacity: 0.72; }
}


.hero-panel {
  isolation: isolate;
  overflow: hidden;
  background:
    linear-gradient(180deg, rgba(255,255,255,0.94), rgba(255,255,255,0.76)),
    linear-gradient(135deg, rgba(13,159,110,0.12), rgba(10,132,255,0.08));
  box-shadow: var(--premium-shadow);
}

/* The panel sheen is gone. It swept a 74%-white diagonal across the panel
   every eight seconds, which is the gloss a phone case gets in a product
   render, and the owner's word for it was cheap. He is right: the panel holds
   the categories a visitor is meant to read, and a bright band crossing them
   on a timer is decoration competing with the one thing on that surface worth
   looking at. Nothing replaces it. The panel is already lifted off the field
   by its own shadow and its own light ground. */


@keyframes tile-float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-5px); }
}

.category-spotlight button,
.solution-card,
.boq-input,
.boq-output,
.product-card,
.brand-cloud button {
  position: relative;
  isolation: isolate;
}

/* `.filters` is deliberately left out of the group above. It is `position:
   sticky` with `top: 106px`; resetting it to `relative` kept that offset and
   pushed the panel 106px down out of flow, landing it on top of the results
   heading in the single-column layout. It only needs the stacking context. */
.filters { isolation: isolate; }

.category-spotlight button::before,
.solution-card::before,
.product-card::before,
.brand-cloud button::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: radial-gradient(260px 180px at var(--mx, 50%) var(--my, 0%), rgba(13,159,110,0.16), transparent 64%);
  opacity: 0;
  transition: opacity 260ms var(--ease);
}

/* The pointer-follow glow is emerald because it lives on white everywhere else,
   and emerald is the accent that carries meaning on a light ground. The brand
   wall is the one place it sits on ink, where emerald goes muddy next to the
   lime already in the field behind it. Same mechanism, band's own colour. */
.brand-cloud button::before {
  background: radial-gradient(260px 180px at var(--mx, 50%) var(--my, 0%), rgba(184, 231, 106, 0.2), transparent 64%);
}

.category-spotlight button:hover::before,
.solution-card:hover::before,
.product-card:hover::before,
.brand-cloud button:hover::before {
  opacity: 1;
}

.product-card {
  transform-style: preserve-3d;
  box-shadow: 0 1px 0 rgba(255,255,255,0.7) inset, 0 1px 2px rgba(0,0,0,0.02);
}

.product-card:hover {
  transform: translateY(-8px);
}

.image-frame {
  background:
    linear-gradient(180deg, #ffffff 0%, #f7f9fb 100%);
}

.image-frame::after {
  content: "";
  position: absolute;
  left: 13%;
  right: 13%;
  bottom: 16px;
  height: 16px;
  border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(0,0,0,0.11), transparent 68%);
  filter: blur(5px);
  opacity: 0.42;
  transform: scaleX(0.72);
  transition: opacity 260ms var(--ease), transform 380ms var(--spring);
}

.image-frame img {
  position: relative;
  z-index: 1;
  filter: drop-shadow(0 10px 18px rgba(0,0,0,0.08));
}

.product-card:hover .image-frame::after {
  opacity: 0.68;
  transform: scaleX(0.86);
}

.product-card:hover .image-frame img {
  transform: translateY(-3px) scale(1.04);
}

.card-actions .primary-action {
  background:
    linear-gradient(180deg, rgba(255,255,255,0.12), transparent),
    var(--ink);
  box-shadow: 0 8px 18px rgba(29,29,31,0.13);
}

.card-actions .primary-action:hover {
  box-shadow: 0 12px 24px rgba(8,122,84,0.20);
}

.filters,
.boq-input,
.boq-output,
.recommendation,
.contact-band,
.partner-hub {
  box-shadow: var(--premium-shadow);
}

.filter-guide {
  background:
    linear-gradient(180deg, rgba(255,255,255,0.82), rgba(236,249,244,0.76));
}

.boq-input textarea {
  background:
    linear-gradient(#fff, #fff) padding-box,
    linear-gradient(135deg, rgba(13,159,110,0.24), rgba(10,132,255,0.14)) border-box;
  border-color: transparent;
}

.boq-match {
  transition: background 160ms var(--ease), transform 200ms var(--spring);
}

.boq-match:hover {
  transform: translateX(3px);
}

.rfq-drawer.is-open {
  animation: drawer-enter 420ms var(--spring) both;
}

@keyframes drawer-enter {
  from { transform: translateX(24px); opacity: 0; }
}

.whatsapp-float {
  animation: whatsapp-breathe 3.2s var(--ease) infinite;
}

/* The same bug the headline had, in the one place it could do the most damage.

   This animated `box-shadow`, and a box-shadow is part of an element's ink
   overflow: changing it changes the overflow rectangle, so Chrome recalculated
   style and repainted on every frame of a 3.2 second loop with no end. The
   headline at least stopped mattering once you scrolled past it. This button is
   `position: fixed`. It is on screen at the hero, in the catalogue, at the
   footer, forever, on every page — which is why the style recalculation count
   stayed at roughly one per frame no matter where the page was scrolled to.

   Measured with the field mask already gone: this alone is worth about six
   frames a second and takes per-frame style recalculation to zero.

   So the shadow stops moving and the halo moves instead. Two static shadows,
   the brighter one on a pseudo-element behind the button, crossfaded by
   opacity while the button itself keeps its transform. Opacity and transform
   are the only two properties the compositor can animate without waking the
   main thread. The effect is the one that was there before. */
@keyframes whatsapp-breathe {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-3px); }
}

@keyframes whatsapp-halo {
  0%, 100% { opacity: 0; }
  50% { opacity: 1; }
}

.whatsapp-float::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 18px 42px rgba(13, 159, 110, 0.32);
  opacity: 0;
  animation: whatsapp-halo 3.2s var(--ease) infinite;
  pointer-events: none;
  will-change: opacity;
  z-index: -1;
}

.toast {
  border: 1px solid rgba(255,255,255,0.16);
  background:
    linear-gradient(180deg, rgba(45,49,57,0.98), rgba(24,28,34,0.98));
}

@media (max-width: 720px) {
  .hero-copy::after { display: none; }
  .hero-panel { border-radius: 28px; }
  .product-card { border-radius: 28px; }
  .card-actions {
    grid-template-columns: 1fr 1fr;
  }
  .card-actions .primary-action {
    grid-column: 1 / -1;
  }
  .image-frame {
    height: 248px;
  }
  .spec-list {
    grid-template-columns: 1fr;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero-copy::after,
  h1 em,
  .hero-panel::after,
  .whatsapp-float {
    animation: none !important;
  }
  .reveal {
    opacity: 1 !important;
    transform: none !important;
  }
}

.partner-hub .section-heading .sub,
.partner-hub .eyebrow { color: rgba(255, 255, 255, 0.68); }

/* Three equal cards in a row is a stock layout, and numbering them 01/02/03
   makes it a stock layout wearing a costume. So these started as three plain
   statements with the boxes taken off and a hairline grouping them, the first
   column carrying more weight than the other two.

   That was right until a later pass gave every card inside a band a faint
   glass ground, and this grid was caught by it: `.ink-band .hub-grid > *`. A
   ground with no padding is the worst of both worlds. The tint drew a
   rectangle whose left edge ran exactly down the first letter of every line
   and whose bottom edge sat on the last descender, so the band read as four
   grey slabs with the copy shoved into their corners, and the seams between
   the columns were just visible enough to look like a mistake rather than a
   choice. The orbs made it worse rather than better: at 4% white the ground is
   not a reading surface, so the field ran at full brightness straight through
   the paragraphs.

   Two coherent answers existed and the borderless one could not fix the
   reading surface, which is the part a person actually complained about. So
   the boxes come back, properly: real padding, a radius, a ground dense enough
   to hold body copy over a live field. The weighting survives — the first
   column is still wider and still set larger. */
.hub-grid {
  display: grid;
  grid-template-columns: 1.4fr 1fr 1fr;
  gap: 18px;
  align-items: stretch;
}

/* Qualified by the band on purpose. `.ink-band .hub-grid > *` is two classes
   and lands later in the file, so a bare `.hub-grid article` would lose the
   ground it is trying to set and the slab would come straight back. Adding the
   band to the selector is the whole reason this reads as three classes for
   what looks like a one-element rule. */
.partner-hub .hub-grid article {
  padding: 26px 28px 28px;
  /* Shape only. The ground these carry is set in P12 near the foot of this
     file, with the rest of the cards that hold running text, because the
     reason for it is the same reason and belongs in one place. */
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 20px;
}

/* The fourth card is the Watchtower's doorway and spans the row: in a
   1.4fr/1fr/1fr grid it would otherwise wrap into the first column alone and
   leave two empty cells beside it, which reads as a layout accident rather
   than an invitation.

   Spanning it is only half an answer, though. Full width with the copy still
   set to a readable measure left two-thirds of the card empty, which is a
   worse accident than the one it fixed. So the wide card splits internally on
   roughly the same 1.4 : 2 rhythm as the row above it: the question on the
   left where the first column's headings are, the answer running across the
   space the other two columns occupy. */
.hub-grid article:nth-child(4) {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: 1.4fr 2.2fr;
  gap: 0 40px;
  align-items: start;
}

/* Centred against the paragraph rather than sitting on its top line. The
   question is three words and the answer is three lines, so top-aligning them
   hangs the heading off the ceiling with a hundred pixels of nothing under
   it. */
.hub-grid article:nth-child(4) h3 { margin: 0; align-self: center; }

.hub-grid article a {
  color: var(--brand-lime, var(--brand-lime));
  text-decoration: underline;
  text-underline-offset: 3px;
}

.hub-grid h3 { margin: 0 0 12px; font-size: 22px; line-height: 1.25; }
.hub-grid p { margin: 0; color: rgba(255, 255, 255, 0.74); line-height: 1.65; }
.hub-grid article:first-child h3 { font-size: 25px; }
.hub-grid article:first-child p { color: rgba(255, 255, 255, 0.85); }

/* Footer wordmark: same whitespace-cropping window at smaller scale */
.site-footer { position: relative; }
.site-footer img {
  height: 48px;
  width: auto;
  margin: -14px 0;
  opacity: 0.9;
}

@media (max-width: 900px) {
  .hub-grid { grid-template-columns: 1fr; gap: 14px; }
  .partner-hub .hub-grid article { padding: 22px 20px 24px; border-radius: 18px; }
  /* The wide card's two columns are the desktop layout's answer to a wide row.
     On one column there is no wide row, so it goes back to being a card. */
  .hub-grid article:nth-child(4) { display: block; }
  .hub-grid article:nth-child(4) h3 { margin: 0 0 12px; }
  .hub-grid article:first-child h3 { font-size: 22px; }
}

/* ---------- Touch feedback ---------- */

/* Hover states never fire on a touchscreen, and a tapped card keeps its -6px
   lift until something else is tapped. On touch devices the lift is dropped and
   replaced with an immediate press response, so taps feel acknowledged. */
@media (hover: none) {
  .product-card:hover { transform: none; }
  .product-card:active {
    transform: scale(0.985);
    box-shadow: var(--shadow-md);
    transition-duration: 90ms;
  }

  .hero-chips button:hover,
  .button.primary:hover { transform: none; }

  .hero-chips button:active,
  .category-spotlight button:active,
  .brand-cloud button:active,
  .solution-card:active,
  .view-pill:active { transform: scale(0.97); }

  .card-actions button:active,
  .card-actions a:active,
  .whatsapp-float:active { transform: scale(0.955); }
}

/* Every decorative animation added above is opt-out. The blanket rule earlier in
   this file neutralises durations; these are named so the fill-mode `both` on
   .reveal-in cannot leave anything mid-transform. */
@media (prefers-reduced-motion: reduce) {
  .reveal-in {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
  .hs-go:hover svg,
  .product-card:hover { transform: none !important; }
}

/* ---------- Modal scroll lock ---------- */

/* Native showModal does not stop the page behind it from scrolling, so a trackpad
   flick over the backdrop moved the catalogue underneath the sheet. Both the
   native and the fallback path take this lock. */
/* Pinned, not just clipped. overflow: hidden leaves the document scrollable in
   principle, and showModal() asks the browser to scroll the dialog into view,
   which with smooth scrolling on the root glides the whole page to the top
   behind the sheet. Fixing the body at its current offset means there is no
   scroll left to animate. The offset itself is set inline by app.js, because
   only it knows where the reader was. */
body.dialog-open {
  position: fixed;
  left: 0;
  right: 0;
  width: 100%;
  overflow: hidden;
}

/* The same lock for a sheet opened while the page is still at the top, which
   is what a /product/ deep link does. Taking <body> out of flow is what stops
   the browser animating its own scroll-into-view, and at offset zero there is
   no such scroll to stop; all that survives of the pin there is a layout shift
   the size of the viewport. Overflow alone, so nothing moves.
   See openProductDialog in app.js. */
body.dialog-open-top {
  overflow: hidden;
}

/* ---------- Long-list scroll performance ---------- */

/* The grid can hold several hundred cards, each with an image, a plate and three
   actions. Skipping layout and paint for the ones off screen keeps scrolling on
   the main thread cheap. contain-intrinsic-size is the measured card height, so
   the scrollbar stays honest and anchors do not drift.

   The cost, and the reason the owner reported "large blank placeholder cards
   below loaded results": a card whose contents are skipped paints nothing but
   its own background, so scrolling faster than the browser can catch up leaves
   plain white rectangles the size of a card. The rows are loaded and correct;
   they simply have not been drawn yet. That is invisible to a test and obvious
   to a person.

   It cannot be fixed from inside the card, because content-visibility skips
   children AND generated content, so a ::before skeleton is skipped with
   everything else. The element's own background is the one thing that still
   paints, so the skeleton is drawn there, in gradients: an image plate, a brand
   bar, two title lines, a part-code chip, a meta line and three button pills,
   at the positions the real content occupies. The real content covers it,
   which is why .product-body and .card-actions are given the panel colour
   below rather than inheriting it.

   The result is that a card being drawn looks like a card, not like a hole. */
@supports (content-visibility: auto) {
  .product-grid > .product-card {
    content-visibility: auto;
    contain-intrinsic-size: auto 430px;

    background-color: var(--panel);
    background-repeat: no-repeat;
    background-image:
      linear-gradient(var(--plate), var(--plate)),
      linear-gradient(var(--line), var(--line)),
      linear-gradient(var(--skeleton), var(--skeleton)),
      linear-gradient(var(--skeleton), var(--skeleton)),
      linear-gradient(var(--skeleton), var(--skeleton)),
      linear-gradient(var(--skeleton), var(--skeleton)),
      linear-gradient(var(--skeleton-strong), var(--skeleton-strong)),
      linear-gradient(var(--skeleton), var(--skeleton)),
      linear-gradient(var(--skeleton), var(--skeleton));
    background-position:
      0 0,          /* image plate            */
      0 236px,      /* the rule under it      */
      18px 252px,   /* brand mark             */
      18px 288px,   /* title, first line      */
      18px 310px,   /* title, second line     */
      18px 338px,   /* part-code chip         */
      18px 372px,   /* primary action         */
      153px 372px,  /* details                */
      227px 372px;  /* ask                    */
    background-size:
      100% 236px,
      100% 1px,
      92px 20px,
      calc(100% - 36px) 13px,
      62% 13px,
      118px 18px,
      125px 38px,
      68px 38px,
      52px 38px;
  }

  /* Opaque, so the skeleton underneath is hidden the moment the card draws.
     Without this the gradients show through the transparent body and every
     rendered card carries a set of grey bars behind its own text. */
  .product-grid > .product-card > .product-body,
  .product-grid > .product-card > .card-actions {
    background: var(--panel);
  }
}

/* ---------- Print ---------- */

/* A quote list gets printed or saved to PDF and walked into a meeting, and a
   product sheet gets attached to an email thread. Both were unusable on paper:
   the fixed header and the floating button printed over the content, the sheet
   printed at 30% of the page inside a scroll container, and the dark buttons ate
   a quarter of a toner cartridge. */
@media print {
  @page { margin: 14mm; }

  :root { --canvas: #fff; --canvas-0: rgba(255, 255, 255, 0); }

  html, body { background: #fff; }

  /* Ambient and navigational furniture carries no information on paper. */
  .aurora,
  .site-header,
  .whatsapp-float,
  .toast-stack,
  .logo-marquee,
  .skip-link,
  .hero-search,
  .hero-chips,
  .hero-secondary,
  .filters,
  .results-toolbar .toolbar-actions,
  .card-actions,
  .detail-cta,
  .detail-utils,
  .drawer-tools,
  .drawer-actions,
  .dialog-close,
  .load-more,
  .related-products,
  .partner-hub,
  .site-footer nav { display: none !important; }

  /* Nothing may clip or scroll: the page is as tall as it needs to be. */
  .product-dialog,
  .dialog-scroll,
  .rfq-drawer,
  .rfq-items {
    position: static !important;
    max-height: none !important;
    height: auto !important;
    overflow: visible !important;
    box-shadow: none !important;
    border: 0 !important;
    transform: none !important;
    backdrop-filter: none !important;
  }

  .product-dialog { width: 100% !important; }
  .product-dialog::backdrop { display: none; }

  /* When a sheet or the quote list is open, print that and nothing else.
     Both lock classes, or printing a deep-linked product page prints the whole
     catalogue behind the sheet. */
  body.dialog-open > *:not(.product-dialog),
  body.dialog-open-top > *:not(.product-dialog),
  body:has(.rfq-drawer.is-open) > *:not(.rfq-drawer) { display: none !important; }

  .rfq-drawer.is-open { display: block !important; width: 100% !important; }

  /* Single column, and never split a line item or a card across a page break. */
  .detail-grid,
  .product-grid,
  .spec-list,
  .rfq-buyer-grid { grid-template-columns: 1fr !important; }

  .product-card,
  .rfq-item,
  .detail-section { break-inside: avoid; }

  .product-card {
    border: 1px solid #ccc !important;
    box-shadow: none !important;
  }

  .image-frame { height: 150px !important; }

  /* Paper has no hover and no colour budget. */
  a { color: #000 !important; text-decoration: none !important; }

  /* A printed link is dead unless the address is on the page — except for the
     ones where the address is noise: a wa.me deep link carries the whole
     prefilled message, and tel:/mailto: already read as the number or address. */
  .detail-source a::after,
  .resource-links a::after { content: " (" attr(href) ")"; font-size: 10px; word-break: break-all; }
  .resource-links a[href^="https://wa.me"]::after,
  .resource-links a[href^="tel:"]::after,
  .resource-links a[href^="mailto:"]::after { content: ""; }

  h1, h2 { font-size: 20pt !important; }
  h3 { font-size: 11pt !important; }
  body { font-size: 10.5pt; line-height: 1.4; }

  /* The desk's number should survive the print. */
  .detail-phone,
  .drawer-note { color: #000 !important; }
}

/* The sheet's heading is focused programmatically when the dialog opens so a
   screen reader announces the product rather than "Close product". It is not a
   keyboard target, so it takes no focus ring — on screen it read as an error
   state, and it was printing as a box around the part code. */
.detail-copy h2[tabindex="-1"]:focus,
.detail-copy h2[tabindex="-1"]:focus-visible { outline: none; }

/* ---------- Manufacturer summary ---------- */

/* One clear opening line, then scannable points. A procurement reader is deciding
   whether this part is the right one, not reading an essay. */
.detail-section p.detail-lede {
  font-size: 16px;
  line-height: 1.6;
  color: var(--ink);
}

.detail-highlights {
  display: grid;
  gap: 9px;
  margin: 14px 0 0;
  padding: 0;
  list-style: none;
}

.detail-highlights li {
  position: relative;
  padding-left: 22px;
  color: var(--ink-2);
  font-size: 15px;
  line-height: 1.5;
}

/* A small square dot. A tick reads as a verified claim; the previous 10x2
   bar read as an en-dash, which the site's own copy rule bans, and a marker
   should obey the same language as the words beside it. */
.detail-highlights li::before {
  content: "";
  position: absolute;
  left: 5px; top: 8px;
  width: 5px; height: 5px;
  border-radius: 2px;
  border-radius: 1px;
  background: var(--green);
}

@media (max-width: 620px) {
  .detail-section p.detail-lede { font-size: 15px; }
  .detail-highlights li { font-size: 14px; }
}

/* ==========================================================================
   The frame, the atmosphere, and the phone           (P1 / P2 / P3)
   ==========================================================================

   Everything below exists because of one sentence the owner said twice: he
   wants the page to look exactly like the reference, and he overruled the
   argument that some of it was too much for a procurement site. So this is not
   a tasteful subset. It is the frame, the three-layer colour wash and the
   separate phone design, ported together.

   Two rules survive that instruction, and neither is taste. Product cards stay
   on a light ground, because that is where a buyer reads a part code off the
   screen and it is what the accessibility pass rests on; the reference does
   the same thing, its own results sit on white. And the atmosphere may not
   cost the first product card, which is measured at the end of this file's
   work rather than assumed.
   ========================================================================== */

/* ---------- P1: the frame ---------- */

/* The root goes graphite so that an overscroll bounce at either end of the
   document shows the page's own dark rather than a flash of white paper. Both
   ends of this document are now dark bands, so white was always the wrong
   answer there. */
html { background: #050b16; }

/* Display type, tightened. The reference sets its headline noticeably larger,
   heavier and much tighter than a default sans, and that tracking is most of
   why it reads as designed rather than typed. No webfont is involved: the app
   the owner likes ships the same system stack this project already uses, so
   the difference was never the typeface. */
.hero-copy h1,
.ink-band h2,
.section-heading h2 {
  font-size: clamp(2.2rem, 6vw, 4.8rem);
  font-weight: 700;
  letter-spacing: 0;
  line-height: 1.02;
  text-wrap: balance;
}

.section-heading h2 { font-size: clamp(1.9rem, 4vw, 3.2rem); }

::selection { background: rgba(184, 231, 106, 0.32); color: #ffffff; }
.paper-field ::selection,
.section:not(.ink-band) ::selection { color: #05070c; }

/* The header stops being a white bar and becomes a lens over the field. Over
   the hero it is nearly transparent so the orbs run behind it; once the reader
   has moved 8px it settles to graphite and grows a hairline, which is the only
   thing separating it from the content beneath. */
/* The reference gets away with a header at 30% black because its hero starts at
   the top of the document and the header floats on it. Ours does not overlap
   the hero, so at rest the header is over light paper and 30% black rendered as
   a grey shelf above the band. It is nearly opaque at rest for that reason, and
   becomes more transparent once the reader has scrolled, because by then there
   is a dark band behind it and the lens effect can actually happen. */
.site-header {
  background: rgba(4, 10, 20, 0.94);
  border-bottom-color: transparent;
}

.site-header.is-stuck {
  /* 0.8 relied on the backdrop blur to stop content reading through, and a
     capture pipeline that skips backdrop-filter showed body text ghosting
     inside the header. Near-opaque holds the glass look and needs no rescue. */
  background: rgba(5, 11, 22, 0.92);
  border-bottom-color: rgba(255, 255, 255, 0.1);
}

/* The lens is a desktop luxury. On a phone the brand wall's white plates
   scroll directly behind the header, and at 80% they ghost through it as pale
   rectangles drifting under the search field — caught in a phone capture, not
   invented. Near-opaque there; the effect is not worth a smudged header on
   the device most visitors hold. */
@media (max-width: 768px) {
  /* Opaque, in both states. The lens was already a desktop luxury here, and
     with the backdrop blur gone there is nothing left to soften what shows
     through: at 0.94 the hero's own chips read as grey words floating inside
     the header, which is what the owner photographed. A header is a surface,
     not a window. */
  .site-header,
  .site-header.is-stuck { background: #050b16; }
}

.site-header .top-strip {
  border-bottom-color: rgba(255, 255, 255, 0.08);
  color: rgba(233, 240, 250, 0.66);
}

.site-header .top-strip a { color: var(--brand-lime, var(--brand-lime)); }
.site-header .nav-links a { color: rgba(233, 240, 250, 0.82); }
.site-header .nav-links a:hover { background: rgba(255, 255, 255, 0.08); color: #ffffff; }

.site-header .header-search {
  background: rgba(255, 255, 255, 0.07);
  border-color: rgba(255, 255, 255, 0.14);
}

.site-header .header-search input { color: #ffffff; }
.site-header .header-search input::placeholder { color: rgba(233, 240, 250, 0.5); }
.site-header .search-icon { stroke: rgba(233, 240, 250, 0.6); }

.site-header .search-kbd {
  background: rgba(255, 255, 255, 0.1);
  border-color: rgba(255, 255, 255, 0.16);
  color: rgba(233, 240, 250, 0.72);
}

.site-header .button.ghost {
  color: #ffffff;
  border-color: rgba(255, 255, 255, 0.22);
  background: rgba(255, 255, 255, 0.06);
}

.site-header .button.ghost:hover { background: rgba(255, 255, 255, 0.14); }
.site-header .mobile-menu span { background: #ffffff; }

/* Film grain over the whole document at 1.5%. Pure gradients read as flat
   digital colour; a little noise is what stops six overlapping blurred orbs
   looking like a screensaver. It is one fixed element, composited, and it
   never takes a pointer event. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: var(--z-suggest);
  pointer-events: none;
  opacity: 0.015;
  mix-blend-mode: overlay;
  background-size: 160px 160px;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 0.7 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}

/* Where a dark band meets the band above it, a 120px fade instead of a butt
   join. Without this a run of dark sections reads as a stack of cards; with it
   the page reads as one continuous surface with content floating on it. */
.ink-band::after {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 120px;
  z-index: 0;
  pointer-events: none;
  background: linear-gradient(180deg, rgba(3, 9, 22, 0.85), transparent);
}

/* ---------- P2: the atmosphere ---------- */


/* ---------- P3: the phone ---------- */

/* This is not a set of tweaks to the desktop design, it is the separate mobile
   design ported alongside it, and it ships in the same commit as P2 on
   purpose. Six 700px orbs each blurred 55px, a conic layer blurred 70px and a
   backdrop-filtered header, all composited on a phone GPU, is how you get a
   band that scrolls at eight frames a second and a Safari tab that reloads
   itself. Everything below is the bill for the layer above. */
@media (max-width: 768px) {
  /* Backdrop filters are the single most expensive thing on this page on iOS,
     because each one forces a readback of everything painted beneath it. The
     header keeps its colour and loses the blur. */
  .site-header,
  .site-header .header-search,
  .hero-panel,
  .compare-tray {
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }

  /* Opaque, not 94%. This rule is the last one to touch the header on a
     phone, so it is the one that decides: at 94% the hero's own chips read
     as grey words floating inside the header, which is what the owner
     photographed. The blur that used to hide that is gone, and a header is a
     surface rather than a window. */
  .site-header { background: #050b16; }

  

  

  /* A scrim under the copy, at a third of what it was.
     At 0.85 this was the phone's version of the band-wide veil that the whole
     field pass exists to avoid: a full-hero sheet, densest at the top, over
     the part of the page the field is supposed to own. On a phone it is most
     of what you see, and it is why the same build that reads as lit on a
     desktop read as grey discs on a handset.
     The headline it was protecting already carries a neon halo in
     text-shadow, which travels with the glyph and is correct on every frame;
     the scrim could only ever be right for one arrangement of the orbs. It
     stays, lightly, because phone type is smaller and the lede under the
     headline has no halo of its own. */
  .hero::before,
  .ink-band > .section-heading::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background: linear-gradient(180deg, rgba(3, 9, 22, 0.3), rgba(3, 9, 22, 0));
  }

  .hero-copy { position: relative; z-index: 2; }

  /* This used to flatten the headline to plain white on a phone, back when the
     treatment was a clipped gradient and unreadable at 390px over a moving
     wash. The neon is the opposite case: solid glyphs with a halo behind them
     are more legible on the small screen, not less, so the phone keeps the
     colour. Only the outermost halo comes off — a 52px radius at phone type
     sizes bleeds into the line above it. */
  /* The pulse comes off with the outer radius. The reason has changed with the
     mechanism: the halo is no longer animated at all, it is a second static
     copy crossfaded on ::after, so what has to go on a phone is that copy. Left
     running it would fade the full desktop halo in over the tighter one below
     and undo it, which is what the old `animation: none` was guarding against
     when the shadow itself was the thing being animated. */
  .hero-copy h1 .metal {
    text-shadow:
      0 0 4px rgba(217, 249, 157, 0.95),
      0 0 11px #a3e635,
      0 0 22px #84cc16;
  }

  .hero-copy h1 em {
    text-shadow:
      0 0 4px rgba(199, 210, 254, 0.95),
      0 0 11px #818cf8,
      0 0 22px #6366f1;
  }

  .hero-copy h1 .metal::after,
  .hero-copy h1 em::after { content: none; }

  .hero { min-height: 100svh; }

  /* The grain is a fixed full-viewport composited layer and contributes almost
     nothing at this size. */
  body::after { display: none; }
}

/* Reduced motion parks the whole wash rather than merely slowing it. The bands
   are composed to read as a still, which is the reason the canvas draws one
   frame and stops rather than being removed. */
@media (prefers-reduced-motion: reduce) {
  
}

/* No filter on the mark, on any surface.
   What was here inverted the light-surface lockup and swung the hue half a
   turn, on the reasoning that invert alone would put the lettering in white but
   lose the lime. It does put the lettering in white. It does not keep the lime:
   a filter applies one transform to every pixel, and the one that whitens black
   lettering takes #BCFD01 to a violet, which the hue rotation then lands on a
   dark desaturated green. Sampled off the rendered header, the chevron was
   arriving at about #2d4a06 against a #05070c ground, near enough to invisible,
   and the owner reported the mark as blurred.
   datasystems-mark-dark.png carries both inks already, so there is nothing left
   to correct at render time. See scripts/generate-brand-marks.mjs. */

/* The hero's field is inset -10% -6% so its drift never shows an edge, which
   was harmless while the band was a quiet gradient. With six blurred orbs and a
   conic aurora in there it spills a full colour wash over the brand marquee
   sitting underneath, and twenty greyscale logos on a moving rainbow is not a
   trust strip. Clip it to the band it belongs to. */
.hero { overflow: hidden; }

/* The field must stay out of flow inside a band.
   `.ink-band > *` sets position:relative on every direct child so the content
   sits above the background, and it has the same specificity as `.field` while
   appearing later in this file, so it silently won and the field became an
   in-flow element. On the contact band, which is a two column layout, that drew
   the whole canvas as a small rectangle in the left column. */
.ink-band > .field {
  position: absolute;
  z-index: -1;
}

/* Text and surfaces on the newly darkened bands.
   Five sections that were written for light paper are now dark, and only h2,
   .sub and .eyebrow had band-scoped colours. Everything else, the card
   headings, list items and inline links, stayed at ink on ink. */
.ink-band h3,
.ink-band h4,
.footer-grid h3,
.ink-band h5,
.ink-band strong,
.ink-band b { color: #ffffff; }

.ink-band li,
.ink-band dd,
.ink-band figcaption,
.ink-band small { color: rgba(233, 240, 250, 0.72); }

.ink-band a:not(.button):not(.brand) { color: var(--brand-lime, var(--brand-lime)); }

/* Cards on a band become glass rather than white slabs. A white card on a live
   colour field reads as a hole punched in the page. */
.ink-band article,
.ink-band .card,
.ink-band .panel,
.ink-band .hub-grid > *,
.ink-band .solution-card,
.ink-band .industry-row > span {
  background: rgba(255, 255, 255, 0.045);
  border-color: rgba(255, 255, 255, 0.1);
  color: rgba(233, 240, 250, 0.82);
  box-shadow: none;
}

.ink-band .button.ghost {
  color: #ffffff;
  border-color: rgba(255, 255, 255, 0.24);
  background: rgba(255, 255, 255, 0.06);
}

.ink-band .button.ghost:hover { background: rgba(255, 255, 255, 0.14); }

/* ---------- The brands a category is deepest in ---------- */

/* Counted from the catalogue, four per tile. On the reference these are the
   most informative thing on a category tile and on ours they have the
   advantage of being true: they are a reading of the 9,187 rows rather than a
   list somebody typed and then stopped maintaining. */
.cat-brands {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  margin-top: 10px;
}

/* The hover ink is a variable, not a constant, and that is the whole point.

   It used to be `color: var(--ink)` on hover, which is #1d1d1f. That was right
   while these chips sat on light paper. The band later went dark, the resting
   state got an .ink-band override, and the hover state did not, so pointing at
   a category turned every brand name on it near-black on a near-black card and
   the names vanished under the cursor. The hover selector also outweighs the
   .ink-band one, so the surface rule had no way to save it.
   Declaring the hover ink beside the resting ink is what stops that: a surface
   can now only change one by changing both. */
.cat-brands span {
  --chip-ink: var(--muted);
  --chip-ink-hover: var(--ink);
  --chip-line-hover: rgba(13, 159, 110, 0.4);
  padding: 3px 8px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.025);
  color: var(--chip-ink);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0;
  white-space: nowrap;
  transition: border-color var(--t-fast) var(--ease-signal),
              color var(--t-fast) var(--ease-signal);
}

.category-spotlight button:hover .cat-brands span {
  border-color: var(--chip-line-hover);
  color: var(--chip-ink-hover);
}

.ink-band .cat-brands span {
  /* On the dark band a chip brightens toward white under the cursor, rather
     than darkening toward the light page's ink. */
  --chip-ink: rgba(233, 240, 250, 0.78);
  --chip-ink-hover: #ffffff;
  --chip-line-hover: rgba(163, 230, 53, 0.5);
  border-color: rgba(255, 255, 255, 0.16);
  background: rgba(255, 255, 255, 0.05);
}

/* ---------- P4: one motion vocabulary ---------- */

/* Three durations and one curve, used everywhere. The page had accumulated a
   dozen ad hoc timings, and the reason the reference feels composed rather
   than busy is not that it animates less, it is that everything on it moves on
   the same terms. */
:root {
  --t-fast: 0.35s;
  --t-mid: 0.6s;
  --t-slow: 0.8s;
  --ease-signal: cubic-bezier(0.16, 1, 0.3, 1);
}

/* The reveal gains a blur that clears.
   Translate alone reads as "the element slid in". Translate plus a defocus
   that resolves reads as "the element arrived", which is the difference
   between the two pages. It is deliberately a small blur: 8px on a heading is
   atmosphere, 20px is a transition effect and draws attention to itself.
   The blur is dropped the moment the element is in, so nothing on the page is
   left carrying a filter it no longer needs. A permanent filter on a section
   promotes it to its own layer for the rest of the session. */
html.js .reveal:not(.in) {
  opacity: 0;
  transform: translateY(28px);
  filter: blur(8px);
}

.reveal {
  transition:
    opacity var(--t-mid) var(--ease-signal),
    transform var(--t-slow) var(--ease-signal),
    filter var(--t-mid) var(--ease-signal);
}

.reveal.in {
  opacity: 1;
  transform: none;
  filter: none;
}

/* A single pulse travelling the rule between sections, once, when the section
   arrives. The reference runs these continuously; ours fire on arrival and
   stop, because a page with seven of them looping is a page with seven things
   asking for attention while somebody is trying to read a part code. */
.ink-band > .section-heading::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -18px;
  width: 100%;
  height: 1px;
  background: linear-gradient(90deg, transparent, rgba(184, 231, 106, 0.9), transparent);
  background-size: 42% 100%;
  background-repeat: no-repeat;
  opacity: 0;
}

.ink-band > .section-heading { position: relative; }

.ink-band > .section-heading.in::after {
  animation: signal-travel 1.6s var(--ease-signal) 0.25s 1 both;
}

@keyframes signal-travel {
  0% { background-position: -45% 0; opacity: 0; }
  15% { opacity: 1; }
  85% { opacity: 1; }
  100% { background-position: 145% 0; opacity: 0; }
}

/* The eyebrow catches the light once as its section arrives, and then never
   again. A looping shimmer on a label that says "02 · CATEGORIES" is a
   distraction; one pass is a flourish. */
/* Scoped entirely to `.in`, so an eyebrow only becomes gradient-filled text at
   the moment it is about to be animated. A permanently transparent text fill is
   one unsupported property away from an invisible label, and the label is the
   only thing naming the section. */
.ink-band .section-heading.in .eyebrow {
  background-image: linear-gradient(
    100deg,
    currentColor 0%, currentColor 38%,
    #ffffff 48%, #ffffff 52%,
    currentColor 62%, currentColor 100%
  );
  background-size: 260% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  animation: eyebrow-shimmer 1.5s var(--ease-signal) 0.4s 1 both;
}

@keyframes eyebrow-shimmer {
  from { background-position: 130% 0; }
  to { background-position: -30% 0; }
}

@media (prefers-reduced-motion: reduce) {
  html.js .reveal:not(.in) { filter: none; }
  .ink-band > .section-heading.in::after,
  .ink-band .section-heading.in .eyebrow { animation: none; }
}

/* ==========================================================================
   Two surfaces move to the dark ground, and the signature layer   (P5)
   ==========================================================================

   The category tiles and the BOQ console were the two things held back from
   the first band pass, on the grounds that both are read rather than looked
   at. Held, not refused: the reference has both on dark, the owner has asked
   twice for the page to look exactly like it, and neither carries a product
   card. So both go dark, and the contrast is measured rather than eyeballed.
   Only the 9,187 product cards stay on light paper, which was always the real
   rule underneath the placeholder one.
   ========================================================================== */

/* ---------- Category tiles on the band ---------- */

.ink-band .category-spotlight button {
  background: rgba(255, 255, 255, 0.045);
  border-color: rgba(255, 255, 255, 0.1);
  box-shadow: none;
}

.ink-band .category-spotlight button:hover {
  background: rgba(255, 255, 255, 0.08);
  border-color: rgba(184, 231, 106, 0.45);
}

.ink-band .category-spotlight b { color: #ffffff; }
/* 8.9:1 on the band ground. The tile blurb is the line that decides whether
   somebody clicks, so it is held well clear of AA rather than at it. */
.ink-band .category-spotlight small { color: rgba(233, 240, 250, 0.78); }
.ink-band .category-spotlight em { color: var(--brand-lime, var(--brand-lime)); }

.ink-band .category-spotlight .cat-glyph {
  background: rgba(184, 231, 106, 0.12);
  border-color: rgba(184, 231, 106, 0.22);
}

.ink-band .category-spotlight .cat-glyph .glyph { stroke: var(--brand-lime); }

/* ---------- The BOQ console ---------- */

/* The one place on the page somebody types. The reference puts its equivalent
   on dark and it works, but a paste area is not a decorative panel: the text
   in it is the reader's own and has to stay legible at 14px mono while a
   colour field moves behind the glass. Hence a heavier surface than the other
   cards on the band, and a solid field rather than a translucent one. */
.ink-band .boq-input,
.ink-band .boq-output {
  background: rgba(255, 255, 255, 0.05);
  border-color: rgba(255, 255, 255, 0.12);
  box-shadow: none;
}

.ink-band .boq-input label,
.ink-band .boq-output h3 { color: #ffffff; }

.ink-band .boq-input textarea {
  background: rgba(255, 255, 255, 0.07);
  border-color: rgba(255, 255, 255, 0.2);
  color: #eef4ff;
}

.ink-band .boq-input textarea::placeholder { color: rgba(233, 240, 250, 0.42); }

.ink-band .boq-input textarea:focus {
  border-color: rgba(184, 231, 106, 0.55);
  outline-color: rgba(184, 231, 106, 0.55);
}

.ink-band .boq-matches,
.ink-band .boq-unmatched { color: rgba(233, 240, 250, 0.8); }
/* 0.64 measured at 4.62:1 against the brightest ground the wash produces,
   which clears AA by a hair. The wash moves, and one sample is one moment, so
   this is the placeholder line held at 0.74 for headroom instead. */
.ink-band .boq-matches p { color: rgba(233, 240, 250, 0.74); }

.ink-band .file-button {
  background: rgba(255, 255, 255, 0.07);
  border-color: rgba(255, 255, 255, 0.2);
  color: #ffffff;
}

/* Corner brackets, from the reference's HUD frame. Four 20px angles rather
   than a full border: the panel reads as an instrument without gaining a box,
   and on a page whose whole subject is network hardware that is the right
   borrowed vocabulary. */
.ink-band .boq-panel > div { position: relative; }

.ink-band .boq-panel > div::before,
.ink-band .boq-panel > div::after {
  content: "";
  position: absolute;
  width: 20px;
  height: 20px;
  pointer-events: none;
  border-color: rgba(103, 232, 249, 0.55);
  border-style: solid;
}

.ink-band .boq-panel > div::before {
  top: 12px; left: 12px;
  border-width: 1px 0 0 1px;
}

.ink-band .boq-panel > div::after {
  bottom: 12px; right: 12px;
  border-width: 0 1px 1px 0;
}

/* ---------- P5: the signature layer ---------- */

/* The liquid-metal headline is gone, and this is the note explaining why,
   because it was the single rule keeping the neon from working.
   It poured a chrome gradient through the first hero line by painting the text
   fill transparent and clipping a background to the glyphs. Beautiful on its
   own; fatal underneath a glow. Every later attempt to light the headline was
   lighting a hole — a pale, semi-transparent letterform with nothing solid to
   throw light — which is exactly what the owner saw and named: dark, oversized,
   not luminous. Living far down the file, it also silently outranked the neon
   rules written six hundred lines above it, so each fix looked correct in the
   source and did nothing on the page.
   The headline's colour now has exactly one home, up in the neon block:
   solid tube, halo in text-shadow. Nothing down here may touch its fill. */

/* A sensor sweep behind the headline, one turn a minute, blurred past the
   point of being a shape. It is the slowest thing on the page on purpose:
   somewhere below the level of a moving object and at the level of the room
   being lit from a rotating source. */
/* Gone, and this one was mine rather than the reference's.
   A 620px cyan conic blur at 55 per cent, turning once a minute, sat over the
   left half of the hero. It adds light, so it never looked like a scrim, but
   a soft cyan wash across the field is exactly what stops six distinct colours
   reading as six distinct colours: every orb under it was pulled toward the
   same hue, which is the "colours are not that visible" the owner described.
   The reference puts nothing behind its headline. The field is the effect. */
.hero-copy::before {
  content: none;
}

.hero-copy { position: relative; }

@keyframes data-ring {
  to { transform: rotate(360deg); }
}

/* The headline's letter-spacing breathing is gone, and it should have gone long
   before the tracking sweep found it.

   It animated letter-spacing by five thousandths of an em over twelve seconds:
   too small to see, which was the stated intent. letter-spacing is a layout
   property, so the browser re-laid-out the largest text on the page on every
   frame for ever, and because that headline carries four blurred text-shadow
   layers out to a 52px radius, every one of those relayouts dragged a very
   expensive repaint behind it. That is the whole of "it is laggy up here and
   buttery the moment I scroll past": below the fold there is no such headline.

   The tracking sweep then set both keyframes to zero, so for one commit it was
   animating nothing at all and still charging full price. All of the cost and
   none of the effect is a good reason to delete something. */

/* The band scanline is gone. It was the reference's HUD sweep, one thin line
   crossing every band every six seconds, and the owner named it exactly:
   "there is something like a line passing". On their page it reads as a
   sensor; on a page whose bands now carry a live aurora it reads as a seam in
   the artwork, which is precisely how it was described. Removed rather than
   slowed, because a slower seam is still a seam.
   Its mobile and reduced-motion gates went with it. */


/* ---------- what a phone gets of all this ---------- */

@media (max-width: 768px) {
  /* The radar ring is a 620px blurred conic layer rotating forever behind the
     first thing anybody reads. It is the most expensive single effect here and
     the least visible at 390px, where most of it is off-screen. */
  .hero-copy::before { display: none; }

  /* One scanline per band across seven bands is seven permanently animating
     composited layers. Kept on the hero, dropped everywhere else, so the
     gesture is still on the page without being on all of it. */

  /* Letter-spacing is not a composited property: it relayouts the headline
     every frame, which on a phone is the difference between a smooth scroll
     and a janky one. */
  .hero-copy h1 { animation: none; }

  
}

@media (prefers-reduced-motion: reduce) {
  .hero-copy h1,
  .hero-copy::before,
  .hero-copy h1 .metal,
  .hero-copy h1 .metal::after,
  .hero-copy h1 em::after { animation: none !important; }
}

/* ==========================================================================
   The touch layer                                                    (P6)
   ==========================================================================
   touch.js supplies the numbers; these rules are the physics. Everything here
   is transform, opacity and a custom property, so nothing the pointer does
   can cause layout. */

/* The magnet. touch.js writes an inline translate capped at 5px; this
   transition is what makes it land like a spring rather than a jump, and what
   eases the button home when the cursor leaves. The overshoot curve is the
   reference's own. */
.magnet {
  transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1);
  will-change: transform;
}

/* The idle sweep. Every 6.5 seconds a light crosses the primary action, once,
   quickly. It is the only looping motion on the light ground and it earns the
   exception: the primary CTA is the one element whose job is to be noticed.
   Paused the moment the cursor arrives, because by then it has worked. */
.button.primary { position: relative; overflow: hidden; }

.button.primary::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.45) 50%, transparent 70%);
  transform: translateX(-120%);
  animation: cta-idle-sweep 6.5s ease-in-out infinite;
  mix-blend-mode: screen;
  pointer-events: none;
  opacity: 0;
}

.button.primary:hover::after { animation-play-state: paused; opacity: 0; }

@keyframes cta-idle-sweep {
  0%, 88%, 100% { transform: translateX(-120%); opacity: 0; }
  92% { opacity: 0.8; }
  96% { transform: translateX(120%); opacity: 0.8; }
  97% { opacity: 0; }
}

/* The specular light. touch.js keeps --tx/--ty under the cursor; the gradient
   lives here, on the dark tiles only. On light paper a white light at overlay
   blend is invisible work, so the light ground never pays for it. */
.ink-band .category-spotlight button,
.ink-band .brand-cloud button,
.ink-band .solution-card {
  position: relative;
}

.ink-band .category-spotlight button::after,
.ink-band .solution-card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: radial-gradient(
    circle 220px at var(--tx, 50%) var(--ty, 50%),
    rgba(255, 255, 255, 0.16),
    transparent 60%
  );
  opacity: 0;
  transition: opacity 0.35s ease;
  pointer-events: none;
  mix-blend-mode: overlay;
}

.ink-band .category-spotlight button:hover::after,
.ink-band .solution-card:hover::after {
  opacity: 1;
}

/* The sonar ring. Sized and placed by touch.js, gone 520ms later. */
.tap-ripple-ring {
  position: absolute;
  border: 1px solid rgba(165, 243, 252, 0.85);
  border-radius: 9999px;
  box-shadow: 0 0 12px rgba(34, 211, 238, 0.6);
  transform: scale(0);
  pointer-events: none;
  animation: tap-ripple-expand 520ms cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}

@keyframes tap-ripple-expand {
  from { transform: scale(0); opacity: 0.9; }
  to { transform: scale(1); opacity: 0; }
}

/* ---------- the hero stage ---------- */

/* The gallery holds eight real catalogue picks and shows four, rotating on the
   reference's 5.4s clock. The swap is a fade on the whole set rather than a
   shuffle of individual cards, because four images trading places reads as a
   glitch where one surface dimming and returning reads as a rotation. */
.hero-gallery { transition: opacity 0.45s var(--ease-signal); }


/* What a phone and reduced motion get: no magnet, no sweep, no light. The
   ripple stays on touch because acknowledging a tap is its whole job, and the
   stage stops turning under reduced motion (touch.js side). */
@media (hover: none), (prefers-reduced-motion: reduce) {
  .button.primary::after { animation: none; }
}

@media (prefers-reduced-motion: reduce) {
  .magnet { transition: none; }
  .tap-ripple-ring { animation: none; opacity: 0; }
  .hero-gallery { transition: none; }
}

/* ---------- The footer becomes the sitemap ---------- */

/* The reference closes on four columns over the dark ground and ours now does
   the same, with one difference of substance: its links point at facade
   routes, ours point back into this page, where a brand link filters 9,187
   real rows. The registrations column is the one piece of pure text on this
   page that does trust work all by itself. */
.ink-footer {
  display: block;
  padding: clamp(48px, 6vw, 80px) var(--pad-x) 32px;
  color: rgba(233, 240, 250, 0.72);
  background: linear-gradient(180deg, #040a12 0%, #030710 100%);
}

/* Same mark as the header, one size up, and for the same reason no filter and
   no oversized frame: the file is trimmed to its ink and already two-tone. The
   negative margins that used to be here were pulling back the dead space around
   the old untrimmed lockup, and would now bite into the mark itself. */
.ink-footer .footer-brand img {
  width: 209px;
  height: 36px;
  margin: 0 0 18px;
  opacity: 1;
}

.footer-grid {
  display: grid;
  grid-template-columns: minmax(260px, 1.4fr) repeat(3, minmax(150px, 1fr));
  gap: clamp(28px, 4vw, 56px);
  align-items: start;
  max-width: 1280px;
  margin: 0 auto;
}

.footer-brand p {
  margin: 0 0 18px;
  font-size: 14px;
  line-height: 1.65;
  max-width: 34ch;
}

.footer-contact { display: flex; flex-wrap: wrap; gap: 10px; }

.footer-contact .button.ghost {
  color: #ffffff;
  border-color: rgba(255, 255, 255, 0.22);
  background: rgba(255, 255, 255, 0.05);
}

.footer-grid h3 {
  margin: 4px 0 14px;
  color: rgba(233, 240, 250, 0.5);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.footer-grid nav { display: flex; flex-direction: column; gap: 9px; }

.footer-grid nav a {
  color: rgba(233, 240, 250, 0.82);
  text-decoration: none;
  font-size: 14px;
  width: fit-content;
  transition: color var(--t-fast) var(--ease-signal);
}

.footer-grid nav a:hover { color: var(--brand-lime, var(--brand-lime)); }

.footer-branches { display: flex; flex-direction: column; gap: 14px; }

.footer-branches address {
  font-style: normal;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 12px 14px;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.04);
  font-size: 13px;
}

.footer-branches b { color: #ffffff; font-size: 14px; }

/* Registration numbers are read character by character when they are read at
   all, which is mono's whole job. */
.footer-branches .gst {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.04em;
  color: rgba(233, 240, 250, 0.6);
}

.footer-branches .pan-india { font-size: 13px; color: rgba(233, 240, 250, 0.55); }

/* The AI image disclosure. One sentence, on every page, in the footer where
   provenance statements live. It reads at the same muted level as the
   baseline: a fact the desk states plainly, not a warning box. */
.footer-disclosure,
.note-disclosure {
  max-width: 1280px;
  margin: 36px auto 0;
  font-size: 13px;
  line-height: 1.55;
  color: rgba(233, 240, 250, 0.55);
}

.note-disclosure {
  margin: 44px 0 0;
  padding-top: 18px;
  border-top: 1px solid var(--line);
  color: var(--faint);
}

.footer-baseline {
  display: flex;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 10px;
  max-width: 1280px;
  margin: 40px auto 0;
  padding-top: 22px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  font-size: 13px;
  color: rgba(233, 240, 250, 0.5);
}

@media (max-width: 900px) {
  .footer-grid { grid-template-columns: 1fr 1fr; }
  .footer-brand { grid-column: 1 / -1; }
}

@media (max-width: 560px) {
  .footer-grid { grid-template-columns: 1fr; }
}

/* ==========================================================================
   Deep Field: the bands become sky rather than ground              (P9)
   ==========================================================================

   The owner's note on the last build was that the background read as black
   with things on it, and that a white panel floating on black looked vague
   rather than impressive. He asked for something closer to an aurora: light
   green leading, constantly moving, resolving through vivid cosmic colour.

   What was actually holding the page dark was one line. The wash under the
   first band pass came out as a pastel rainbow, and the fix at the time was a
   flat graphite veil at 62% laid back over everything. That fixed the rainbow
   by throwing away the colour, and it is why every band since has read as
   near-black. The veil is now a tenth of that and the legibility it was
   buying is bought properly instead: locally, behind the text, where it is
   actually needed, rather than globally over artwork nobody can then see.

   Four changes, in the order they matter:

     1. the ground stops being near-black and becomes deep sky, with real
        indigo and teal in it, so even an unlit corner has colour;
     2. two aurora curtains drift across it on long counter-periods, which is
        the light-green-into-everything the owner described. Curtains rather
        than more orbs: an aurora is a sheet, and sheets are what make a sky
        read as weather instead of as decoration;
     3. the veil drops from 0.62 to 0.06 and a scrim appears behind copy only;
     4. the hero panel stops being a white slab and becomes dark glass, so the
        product photographs inside it read as lit tiles on an instrument
        rather than as a hole cut in the sky.
   ========================================================================== */

/* 1. The ground. Deep sky, not black: an unlit corner of this is still a
      colour, and the two ends differ so the band has a top and a bottom. */


.ink-band {
  background:
    radial-gradient(120% 90% at 82% 0%, rgba(52, 211, 153, 0.14), transparent 58%),
    radial-gradient(100% 80% at 8% 100%, rgba(99, 102, 241, 0.18), transparent 62%),
    linear-gradient(180deg, #050e20 0%, #05071a 52%, #030c16 100%);
  background-color: #050e20;
}

.ink-footer {
  background:
    radial-gradient(90% 70% at 80% 0%, rgba(52, 211, 153, 0.1), transparent 60%),
    linear-gradient(180deg, #05101c 0%, #04091a 100%);
}


/* Position and scale both move, so the sheet folds as it travels rather than
   sliding past like a backdrop on rails. */
@keyframes curtain-a {
  0%, 100% { background-position: 0% 20%; transform: scale(1); }
  33% { background-position: 60% 80%; transform: scale(1.12); }
  66% { background-position: 100% 30%; transform: scale(1.04); }
}

@keyframes curtain-b {
  0%, 100% { background-position: 100% 80%; transform: scale(1.08); }
  50% { background-position: 20% 10%; transform: scale(1); }
}


/* 3. The veil becomes a vignette rather than a sheet. Two curtains on screen
      blend can only ever add light, so where they cross the band was going
      milky and losing its depth. A vignette puts the dark back at the edges
      and leaves the lit centre alone, which is how a sky actually looks. */


/* The per-band scrims that used to live here are gone with the per-band
   fields. There were four generations of them: a soft pool behind the copy, a
   directional ramp across the hero, a vertical ramp across each band, and a
   nearly opaque top fifth. Every one was an attempt to light seven separate
   rectangles consistently, which is not a solvable problem. One field with
   one veil, above, replaces all of it. */

/* Cards are a white film again, at the reference's four per cent.
   ------------------------------------------------------------------------
   Everything from the next paragraph down to the rule is the argument for
   dark cards. It is kept because it was right about the problem and wrong
   about this page in a way worth being able to see. Read it, then read this.

   The whole case for a 93 per cent card rested on the paragraph about glass:
   a dark tile was acceptable because 24px of backdrop blur put the field
   several centimetres out of focus behind it, so an orb became a soft wash of
   its own hue rather than a dot with an outline. That blur is gone. It was
   removed, correctly, when backdrop-filter over an animating canvas turned
   out to be what was costing the frames. What was left was the half of a pair
   that only ever made sense with the other half: near-black tiles, no lens,
   and a field nobody could see behind them.

   Measured over the whole page rather than the hero, which is what the owner
   asked for and what scripts/field-audit.mjs reports on demand:

     categories   twelve buttons at 0.93, covering 58% of the section
     signals      a column at 0.90 over 60%, and its rows at 0.55 over 51%
     solutions    eight cards at 0.93 over 53%
     partner hub  four at 0.93 over 40%
     bulletin     six notes at 0.93 over 43%
     BOQ          input and output at 0.93 over 42% between them

   That is most of the page, and the field was put out under all of it. The
   hero panel was never a special case; it was the one that got looked at
   first.

   The reference has no dark card anywhere on a dark section. Every tile it
   draws is `bg-white/[0.04]` over a hairline white border, and the orbs run
   straight through. It costs nothing per frame either: a flat translucent
   fill composites, it does not recompute, which is the difference between it
   and the blur that had to go.
   ------------------------------------------------------------------------

   Cards carry their own dark now, rather than being a 4.5% white film that
   inherits whatever the sky is doing behind them. This is what lets the
   aurora be as vivid as it is: a tile is legible because the tile is dark,
   not because the whole band was dimmed to make it so.

   The depth of field is the second half of that sentence, and it took a
   genuinely absurd bug to notice it was missing. An orb drifted over the full
   stop in "Choose the material first." and, at 58% glass behind an 8px blur,
   came through as a hard bright ring sitting exactly where the period was. It
   read as a letter. The heading said "first O" and the markup said nothing of
   the sort.

   That is a lens problem, not a brightness problem. Eight pixels of blur does
   not defocus a 6px bright core — it smears it slightly and leaves it legible
   as a dot, so the field stayed in focus at every depth of the page and
   competed with the type at every one. The fix is to make the glass behave
   like glass: 24px of blur puts anything behind a card several centimetres
   out of focus, and the saturate keeps the colour that survives that blur
   worth having, so an orb passing behind a tile becomes a soft wash of its own
   hue rather than a dot with an outline.

   Dimming was the alternative and it is the wrong one. The orbs stay exactly
   as bright as they were, and in the negative space between cards — which is
   most of every band — they are untouched. They just fall out of focus where a
   reader is reading, which is what happens to a background in a photograph and
   what should have been happening here all along. */
.ink-band article,
.ink-band .card,
.ink-band .panel,
.ink-band .hub-grid > *,
.ink-band .solution-card,
.ink-band .industry-row > span,
.ink-band .category-spotlight button,
.ink-band .boq-input,
.ink-band .boq-output {
  background: rgba(255, 255, 255, 0.04);
  border-color: rgba(255, 255, 255, 0.1);
}

.ink-band .category-spotlight button:hover {
  background: rgba(255, 255, 255, 0.08);
  border-color: rgba(184, 231, 106, 0.5);
}

/* The plate's count sits on the band rather than on the white plate above
   it, so the button carries enough dark of its own to hold the figure. */
.ink-band .brand-cloud button { background: rgba(255, 255, 255, 0.05); }

/* 4. The panel. A white slab on a lit sky is the one thing on the page that
      read as unfinished, because it neither joins the sky nor sits on it. As
      dark glass it becomes part of the instrument, and the product plates
      inside it stay light, which is where white belongs: under a photograph
      of a part, not around it. */
/* Four per cent white, which is what the reference puts on its cards, in
   place of a dark slab at 82 and 74 per cent.
   This panel is a quarter of the first viewport and it was the single most
   opaque thing on the page: the field ran behind it and none of it came
   through, so the right half of the hero was a hole cut in the sky. The
   reference has no dark card anywhere on its dark sections. Its tiles are
   `bg-white/[0.04]` over a hairline border, and the orbs read straight
   through them, which is what makes its hero look lit and ours look covered.
   The heavy drop shadow goes with it: a shadow that dark under a translucent
   card just paints the field out around the edges instead of under it. */
.hero-panel {
  background: rgba(255, 255, 255, 0.04);
  border-color: rgba(255, 255, 255, 0.1);
  box-shadow: none;
}

.hero-panel .hero-points li { color: rgba(233, 240, 250, 0.86); }
.hero-panel .hero-points li > span { background: rgba(52, 211, 153, 0.16); }
.hero-panel .panel-note { color: rgba(233, 240, 250, 0.82); }

/* Text that sits on open sky rather than on a card gets its own shadow.
   This is the only honest fix for type over moving imagery: a scrim can be
   measured at one instant and be wrong at the next, because the curtain has
   drifted, whereas a shadow travels with the glyph and is correct at every
   frame by construction. Two of these tokens were measured at 3.84 and then
   3.18 on consecutive runs for exactly that reason. */
.ink-band .section-heading .sub,
.ink-band .section-heading p,
.hero-copy .lede,
.hero-panel .panel-note,
.ink-band .industry-row > span {
  text-shadow: 0 1px 3px rgba(3, 8, 18, 0.9), 0 2px 14px rgba(3, 8, 18, 0.75);
}

.ink-band h2,
.ink-band h3,
.hero-copy h1,
.hero-copy .eyebrow,
.ink-band .eyebrow {
  text-shadow: 0 2px 18px rgba(3, 8, 18, 0.55);
}


/* ---------- the phone, and reduced motion ---------- */

@media (max-width: 768px) {
  
  
  

  /* With one still curtain the sky is calmer, so the scrim can be lighter. */
  .hero-copy::after,
  .ink-band > .section-heading::before { inset: -10% -6%; }
}

@media (prefers-reduced-motion: reduce) {
  
}

/* ==========================================================================
   One sky for the whole document                                    (P10)
   ==========================================================================

   The owner's verdict on the last build, and he was right: the background was
   a crappy job next to the reference, and the section beginnings were not
   visible. Both had the same cause, and it was architectural rather than a
   matter of taste.

   The reference mounts its background ONCE, at the app shell, fixed to the
   viewport, and every section is simply transparent over it. This port put a
   separate field inside each band: seven auroras, seven canvases, seven
   veils. Two consequences followed inevitably. Every band boundary was a hard
   horizontal seam, because two independently lit rectangles meet at an edge.
   And each band needed its own scrim for its own text, so the scrims differed
   from band to band and the joins showed twice over: once in the artwork and
   once in the veil.

   No amount of tuning fixes that. Nine passes of adjusting curtain opacity,
   veil strength and ramp shape were all rearranging the same mistake. What
   fixes it is doing what the reference does: one field, one veil, and
   sections that carry no ground of their own at all.

   So: .page-field is fixed to the viewport and holds the aurora, the orbs and
   the canvas. Every dark section is transparent. One global scrim sits over
   the whole field, so the veil over the type is identical everywhere and
   there is nothing left to seam. Light sections paint opaque over the top,
   which is how the catalogue keeps its paper.
   ========================================================================== */

/* Near black, and that is the entire ground.
 *
 * The correction here is a subtraction. I read "aurora light green changing
 * into vibrant universe colours" as an instruction to build a sky, so a
 * rotating conic aurora and six 700px blurred colour blobs went in behind the
 * canvas, and a sky bright enough to see is bright enough to bury the orbs, so
 * it needed a 50% graphite scrim, which buried them anyway.
 *
 * The reference never had a sky. Its background component is the canvas and
 * nothing else, over --graphite, oklch(0.15 0.02 258): effectively black with
 * a trace of blue. The orbs are not lit BY a background, they ARE the
 * background, and they read because nothing behind them is competing.
 *
 * Measured over the hero, four frames half a second apart, before and after:
 *
 *                    aurora + scrim     canvas on black
 *   pixels lit             84.0%              2.9%
 *   pixels carrying colour 17.4%              0.8%
 *   bright cores            0.53%             0.59%
 *   eyebrow contrast        4.87:1           10.17:1
 *
 * Read that honestly: this is a much DARKER page, not a brighter one, and
 * less of the frame is in motion. That is the point. The old field lit 84% of
 * the screen and put colour across 17% of it, and the orbs were lost inside
 * their own background — the same count of bright cores, spread over a wash
 * that never let any one of them read as a light. Now the only thing painting
 * is the thing that was meant to, so the same lights are discrete and legible
 * against black, and the type gained a contrast ratio it never had.
 */
.page-field {
  position: fixed;
  inset: 0;
  /* Pinned to the LARGEST the viewport ever gets, not the current one.
     A phone hides its URL bar as you scroll and shows it again as you scroll
     back, which changes the viewport height every few hundred pixels of
     travel. With inset:0 alone the field resized on each of those, and a
     resize reseeds it: the orbs stopped dead and re-formed somewhere else,
     over and over, for the whole length of a scroll. lvh is the tall state,
     so the height never changes, so nothing reseeds and the drift is
     continuous. The extra strip below the fold is never seen. */
  height: 100vh;
  height: 100lvh;
  z-index: -2;
  overflow: hidden;
  pointer-events: none;
  /* Flat, and the same flat the reference uses: #05070c, the literal value it
     puts on every dark section. Ours was a radial wash whose lightest stop,
     #0c1526, sat at the top of the viewport, which is exactly where the hero
     is. The orbs draw with `lighter`, so what they add is judged against
     whatever is underneath, and a lighter ground under the brightest part of
     the page is the one place a wash costs the most. It read as haze rather
     than as a gradient, and it is why the field looked flatter here than in
     the reference while the canvas maths was identical to it. */
  background: #05070c;
}

/* The canvas fills the fixed host. No mask on it, and that sentence is the
   single largest frame-rate change on this page.

   It carried the reference's feather — `linear-gradient(to bottom,
   transparent 0%, black 3%, black 97%, transparent 100%)` — copied across
   because the look is the point. Two things were wrong with the copy, and they
   compound.

   The reference puts that mask on a WRAPPER DIV, and its canvas is one per
   dark section, `absolute inset-0` inside the band. The mask feathers a
   section into the page above and below it, which is a thing worth having, and
   it applies to a box the size of that section.

   Ours is one canvas, `fixed`, the size of the viewport, and the mask was on
   the canvas itself. So it did not feather a section into a page. It faded the
   top three per cent and the bottom three per cent of the SCREEN, permanently,
   at every scroll position — a dark bar pinned across the top of the viewport,
   which is exactly where the hero headline is. It was never the reference's
   effect. It was the reference's declaration in a place that gave it a
   different meaning.

   And it was expensive in a way a per-section mask is not. A mask on an
   element whose contents change every frame makes the compositor re-run the
   masked raster over the whole layer every frame: at 1440x1000 and a device
   pixel ratio of 2, that is 2.9 million pixels re-masked sixty times a second,
   for a fade nobody asked for. Measured at the hero, dpr 1, three-second
   samples: with the mask, 25.2fps and 28 stalls in 76 frames; without it,
   35.0fps and 7 in 105. Nothing else on the page came close — stopping the
   whole orb loop was worth 25.2 to 25.0.

   The feather itself is kept. It is drawn once, below, as a static overlay
   that never repaints. */
.page-field .field-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
}

/* The feather, as paint rather than as a mask.

   The canvas composites over #05070c, so laying #05070c back over its top and
   bottom edges is arithmetically the same picture the mask produced. The
   difference is entirely in when the work happens: a mask is re-applied every
   time the thing under it changes, and this is a static gradient on a static
   box, rasterised once at load and composited from cache forever after. */
.page-field::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background:
    linear-gradient(to bottom, #05070c 0%, rgba(5, 7, 12, 0) 3%),
    linear-gradient(to top, #05070c 0%, rgba(5, 7, 12, 0) 3%);
}

/* Sections stop carrying grounds. This is the whole fix in four lines: with
   nothing painted per band, there is no band to see the edge of. */
.hero,
.ink-band,
.ink-band::before,
.ink-band::after,
.hero::after {
  background: none;
}

.hero,
.ink-band {
  background-color: transparent;
  overflow: visible;
}

/* The two remaining light grounds paint opaque, over the sky rather than
   through it, because a product grid is read and a part code is copied off
   it. That was always the one rule under all of this. */
#catalogue {
  position: relative;
  z-index: 0;
  background: var(--canvas);
}

/* The marquee used to be in that rule, painting opaque paper. It was built as
   a bridge between the dark bands above and the light catalogue below, back
   when only some of the page was dark. Every band went near-black after that,
   and the bridge stopped bridging: it became a pale slab sitting on a dark
   page with two masked edges, which is what the owner was describing when he
   said a grey scroller appears out of nowhere.
   It belongs to the dark page now. The transition into the catalogue is
   already drawn by #catalogue::before, which is an 80px gradient sitting above
   the grid, so nothing is lost by taking the slab out of the middle. */
.logo-marquee {
  position: relative;
  z-index: 0;
  background: transparent;
}

/* #desk used to be in that list and it was the seam. It is editorial content,
   it belongs on the sky, and it is a band now. Removing it from the rule is
   the fix; the later class-based override could never win, because an id
   outranks any number of classes however far down the file it sits. */
#desk { position: relative; z-index: 0; }

/* The join between sky and paper, in both directions, so neither meets the
   other at a ruled line. */
#catalogue::before {
  content: "";
  position: absolute;
  inset: -44px 0 auto 0;
  height: 44px;
  pointer-events: none;
  background: linear-gradient(180deg, var(--canvas-0), var(--canvas));
}

/* The marquee cannot use that. It clips itself three ways over — overflow
   hidden to hold the moving track, `contain: paint`, and a horizontal mask to
   fade the ends — so a fade parked outside its box is thrown away before it
   ever paints. Both of its edges have been ruled lines since the sky went in,
   and it sits directly under the hero, which makes it the first hard cut on
   the page and the one the owner kept pointing at.
   The fix has to live inside the box, so the ground itself is the gradient:
   sky at the very edge, paper by the time the logos start. Nothing bleeds
   outside, so nothing can be clipped. Logos keep full opacity because it is
   the background that melts, not the element. */
/* Nothing to melt any more: the strip is transparent and the field runs behind
   it like every other band. The plates carry their own white, which is what
   lets a dark wordmark and a bright one share a row. */
.logo-marquee { background: transparent; }

/* Light type, because the ground under it is the field now. */
.logo-marquee .marquee-head h2 { color: #ffffff; }
.logo-marquee .marquee-head p { color: rgba(233, 240, 250, 0.72); }

/* ---------- The brand sheet ---------- */

/* Same chassis as the product sheet, sized for prose rather than a spec
   table. The plate keeps its white ground inside the sheet for the same
   reason it keeps it on the wall: the artwork is not ours to recolour. */
.brand-dialog { max-width: 640px; }

.brand-profile-head {
  display: flex;
  align-items: center;
  gap: 18px;
  margin-bottom: 18px;
}

.brand-profile-head .brand-plate {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 132px;
  height: 76px;
  padding: 10px 14px;
  border-radius: 14px;
  background: #ffffff;
  border: 1px solid var(--line);
}

.brand-profile-head h2 { margin: 0 0 4px; font-size: 26px; }

.brand-line-note {
  margin: 0;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

/* The story is the sheet now: the inventory arithmetic that used to sit
   under it is gone at the owner's instruction, so the prose takes the room
   and a size worth the click. */
.brand-story { margin: 0 0 22px; font-size: 17px; line-height: 1.75; color: var(--ink-2); }

.brand-desk {
  padding: 16px 18px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  margin-bottom: 20px;
}

.brand-desk h3 {
  margin: 0 0 8px;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--green-deep);
}

.brand-desk p { margin: 0; line-height: 1.65; color: var(--ink-2); }
.brand-desk strong { display: block; margin-top: 10px; color: var(--ink); }

.brand-actions { display: flex; flex-wrap: wrap; gap: 12px; }

@media (max-width: 560px) {
  .brand-profile-head { flex-direction: column; align-items: flex-start; }
}

/* The quiet line under the contact band's hours: a reader whose project has
   slipped is exactly who is on this page at 11pm, and the link is pitched at
   them rather than at everyone. */
.contact-waiting a {
  color: rgba(184, 231, 106, 0.85);
  text-decoration: underline;
  text-underline-offset: 3px;
}
.contact-waiting a:hover { color: #d6f29a; }

/* ---------- About: the company profile, on the page ---------- */

/* The owner's Canva profile, retold rather than reproduced. The original PDF
   is hosted for download beside it, which is exactly why this copy is allowed
   to be warmer than the document: nobody needs the page to BE the PDF when the
   PDF is one click away. Treatment is the reference's dark-glass system: mono
   stat tiles, three glass cards for before/during/after, and the mission line
   in lime, which is the one lime element this band spends. */
.about-stats {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 10px;
  margin-bottom: 28px;
}

.about-stats > div {
  display: grid;
  gap: 4px;
  padding: 16px 18px;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.04);
}

.about-stats b {
  font-family: var(--mono);
  font-size: clamp(22px, 2.2vw, 30px);
  font-weight: 600;
  color: var(--brand-lime);
  letter-spacing: 0;
  font-feature-settings: "ss01", "ss02";
}

.about-stats span {
  font-size: 12px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
}

.about-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
}

.about-grid article {
  padding: 20px;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.04);
  transition: transform 300ms var(--ease), border-color 300ms var(--ease), background 300ms var(--ease);
}

.about-grid article:hover {
  transform: translateY(-2px);
  border-color: rgba(184, 231, 106, 0.6);
  background: rgba(255, 255, 255, 0.07);
}

.about-grid h3 { margin: 0 0 10px; font-size: 17px; color: #ffffff; }
.about-grid p { margin: 0; font-size: 14px; line-height: 1.65; color: rgba(255, 255, 255, 0.68); }

.about-close { max-width: 820px; margin-top: 30px; }
.about-close p { margin: 0 0 20px; color: rgba(233, 240, 250, 0.8); line-height: 1.7; }
.about-close em {
  display: block;
  margin-top: 12px;
  font-style: normal;
  font-weight: 600;
  color: var(--brand-lime);
}

.about-actions { display: flex; flex-wrap: wrap; gap: 12px; }

@media (max-width: 900px) {
  .about-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .about-grid { grid-template-columns: 1fr; }
}

/* ---------- what the phone gets ---------- */

@media (max-width: 768px) {
  /* One fixed background layer on a phone is cheaper than seven, which is the
     other quiet dividend of this change: the field is now viewport sized no
     matter how long the document is. */
  .page-field { background-attachment: scroll; }
}

/* The body's own ground has to go, or it paints over a field that sits behind
   it: a fixed layer at z-index -2 is still behind the body's background box.
   The root keeps the deep sky so an overscroll bounce at either end shows the
   page's own colour. */
html { background: #030d18; }
body { background: transparent; }

/* The old body aurora is gone from the markup; its rules would otherwise sit
   in the stylesheet forever describing elements that no longer exist. */
.aurora,
.blob,
.blob-a,
.blob-b,
.blob-c { display: none; }

/* The hero's copy sits on the most open part of the sky, and with the per-band
   ramp gone it measured 4.61:1 and 4.89:1: passing, but by less than any other
   token on the page. Rather than reintroduce a section-shaped scrim, which is
   the thing that caused the seams, the shadow under the type is deepened. A
   shadow travels with the glyph, so it costs the sky nothing. */
.hero-copy .eyebrow,
.hero-copy .lede {
  text-shadow: 0 1px 3px rgba(3, 8, 18, 0.95), 0 2px 16px rgba(3, 8, 18, 0.85);
}

.hero-copy .lede { color: rgba(240, 246, 255, 0.96); }
.hero-copy .eyebrow { color: #c6f07a; }

/* The seam the owner kept pointing at, found by measuring rather than by
   looking: a mean-row scan across the junction showed a 533-point jump from
   rgb(245,245,247) to rgb(72,59,72) in one pixel row. That was #desk, the
   signals section, which had been given an opaque light ground and
   so sat in the middle of the sky as a white block with a knife edge along the
   bottom of it. One continuous field behind the page cannot help with a
   section that paints over the top of it.

   Editorial content belongs on the sky, so #desk becomes a band like the rest
   and the page is left with exactly one light region: the catalogue, where
   part codes are read and copied. */
.section.desk-columns.ink-band { background: none; }

.ink-band .desk-grid > * {
  background: rgba(255, 255, 255, 0.04);
  border-color: rgba(255, 255, 255, 0.1);
}


/* Every light region now meets the sky on a fade at BOTH edges. The top fades
   existed; the bottom ones did not, which is precisely why the cut showed at
   the foot of a light block and not at its head. The marquee handles its own
   two edges in its background, above, because it clips. */
#catalogue::after {
  content: "";
  position: absolute;
  inset: auto 0 -44px 0;
  height: 44px;
  pointer-events: none;
  background: linear-gradient(180deg, var(--canvas), var(--canvas-0));
}


/* Everything else still carrying paper inside a band. Found by walking the
   DOM and measuring computed luminance rather than by reading the stylesheet,
   because these are five different components written at five different times
   and no grep would have caught the set. `.brand-plate` is deliberately
   excluded: white plates are how brand marks are meant to sit on a dark wall,
   and that one is the reference's own treatment. */
.ink-band li.signal,
.ink-band .boq-unmatched,
.ink-band .solution-card li,
.ink-band .solution-card ul > li {
  background: rgba(255, 255, 255, 0.03);
  border-color: rgba(255, 255, 255, 0.09);
  color: rgba(233, 240, 250, 0.86);
}

.ink-band li.signal p,
.ink-band li.signal .signal-body { color: rgba(233, 240, 250, 0.8); }
.ink-band li.signal h3,
.ink-band li.signal h4 { color: #ffffff; }

.ink-band .boq-unmatched { color: rgba(233, 240, 250, 0.82); }


/* ==========================================================================
   A ground a paragraph can be read on                                 (P12)
   ==========================================================================
   Every card on a band is a few per cent of white over whatever the field is
   doing, which is the reference's treatment and the right one for a chip, a
   brand plate or a category button: a short label, read in one glance, with an
   orb drifting behind it looking like depth.

   It is the wrong one for a paragraph. Four lines take four seconds to read,
   and in four seconds an orb crosses two of them. Screenshotted, the partner
   hub had a bright core sitting on the "s" of "so" and another on the "e" of
   "current", and the Signals column had them through every abstract. The word
   for that is not depth.

   The band-wide veil is still the wrong answer and so is dimming the field,
   for the reason the earlier pass gives: both put the lights out everywhere to
   fix the few hundred square pixels where somebody is reading. What changes
   here is only which colour the card is made of. A few per cent of white is
   transparent to a bright core — white over bright stays bright. Sixty per
   cent of the page's own near-black is not: an orb behind it survives as a
   soft coloured wash, still moving, still its own hue, no longer legible as a
   dot. In the negative space between the cards, which is most of every band,
   nothing changes at all.

   It costs nothing per frame. This is a flat translucent fill, the same as
   what it replaces; it composites and does not recompute. The 24px backdrop
   blur that solved this once was removed for exactly that reason and is not
   coming back.

   Scoped to the cards that carry running text. A category button with two
   words on it keeps the bright glass. */
.partner-hub .hub-grid article,
.ink-band li.signal,
.ink-band .boq-unmatched {
  background: rgba(7, 12, 22, 0.6);
  border-color: rgba(255, 255, 255, 0.12);
}


/* ==========================================================================
   Reading pools                                                       (P8)
   ==========================================================================
   The depth-of-field pass above fixed every surface that HAS a surface. It
   does nothing for the copy that sits on bare field — section headings, their
   standfirsts, the band eyebrows — and that is the copy the reader meets
   first in every band. On the "Two sides of every project" band the field runs
   at full brightness straight through the paragraphs.

   The instinct is a band-wide veil. That was built once, shipped, and was
   wrong: it dimmed the orbs everywhere to solve a problem that exists only
   where the type is, and the owner named the result exactly — the orb play
   looked dull. So this is the opposite move. A pool is an ellipse of the
   page's own dark, sized to the text block, feathered to nothing well before
   its edge, and sitting behind the type only. Two-thirds of the way out it is
   already invisible; the negative space between blocks never sees it.

   Feathering is the entire trick, and the reason the stops are uneven. A
   linear fade to transparent leaves a visible oval — the eye finds the edge of
   a soft shape far more readily than it finds the shape itself. Easing the
   alpha down early and long means there is no edge to find, only copy that
   happens to sit on slightly deeper dark. */

.section-heading,
.contact-band > div:first-child {
  position: relative;
}

.section-heading::before,
.ink-band.contact-band > div:first-child::before {
  content: "";
  position: absolute;
  /* Well outside the text box. A pool the size of its block has its densest
     part under the type and its edge under the type too, which is the one
     place the edge must not be.

     Vertical inset in pixels and horizontal in per cent looks inconsistent and
     is deliberate. The first version used per cent for both, so the pool
     scaled with the block: a tall three-line heading got a generous one and
     "Built for real sites." — two lines, the shortest block on the page — got
     almost none. That is backwards. A short heading is large type sitting on
     bare field and needs the pool most, not least. Pixels vertically give
     every block the same feathering distance whatever its height; per cent
     horizontally still lets a wide block spread wider than a narrow one, which
     is the one axis where scaling with the box is the right instinct. */
  inset: -60px -9%;
  z-index: -1;
  pointer-events: none;
  /* Cut to a fifth of what it was. At 0.82 this pool was a dark disc the width
     of the heading with the field extinguished under it, and there is one
     behind every heading on the page. The reference has none at all: its
     headings are white type straight on the field. Ours keep a trace, because
     unlike the reference our headings sit over a field that is fixed rather
     than per-section and can drift a bright core directly behind a word. A
     trace plus the text-shadow these already carry holds the type without
     putting the lights out. */
  background: radial-gradient(
    62% 58% at 34% 50%,
    rgba(3, 8, 18, 0.17) 0%,
    rgba(3, 8, 18, 0.15) 26%,
    rgba(3, 8, 18, 0.1) 48%,
    rgba(3, 8, 18, 0.05) 68%,
    rgba(3, 8, 18, 0.015) 84%,
    rgba(3, 8, 18, 0) 100%
  );
}

/* The pool is anchored left because the type is. Centred headings get a
   centred pool, or the copy sits on the bright side of its own shadow. */
.section-heading.is-centred::before,
.section-heading[style*="center"]::before {
  background: radial-gradient(
    58% 58% at 50% 50%,
    rgba(3, 8, 18, 0.17) 0%,
    rgba(3, 8, 18, 0.15) 26%,
    rgba(3, 8, 18, 0.1) 48%,
    rgba(3, 8, 18, 0.05) 68%,
    rgba(3, 8, 18, 0.015) 84%,
    rgba(3, 8, 18, 0) 100%
  );
}

/* Light bands have no field behind them and would get a grey smudge. */
.section:not(.ink-band) .section-heading::before,
.paper-field .section-heading::before { display: none; }

/* A phone is nearly all reading area, so a pool per block would add up to the
   band-wide veil this pass exists to avoid. The cards there already carry
   their own dark, and the hero is the only place the field is meant to be the
   loudest thing on screen. */
@media (max-width: 768px) {
  .section-heading::before,
  .ink-band.contact-band > div:first-child::before { display: none; }
}


/* ==========================================================================
   Measure, balance, and where an anchor lands                          (P9)
   ==========================================================================
   Three separate things, grouped because they are all the same mistake: type
   set by the width of its container rather than by what a person can read. */

/* 1. Measure. A standfirst inside an 820px heading block runs to roughly 95
      characters at desktop, and the eye starts losing its place returning to
      the left margin somewhere past 75. The heading itself can stay wide — a
      three-word line has no return sweep to lose — so this caps the running
      text only. */
.section-heading .sub,
.section-heading > p,
.ink-band .hub-grid > * > p,
.about-band p,
.partner-hub .hub-grid p {
  max-width: 68ch;
}

/* 2. Balance. "If you are the one installing it" broke as five words and then
      a lone "it", which is the single most conspicuous thing a heading can do
      — the reader's eye is drawn to exactly the word carrying the least. The
      browser will distribute the words evenly across the lines if asked, and
      it is only worth asking on short blocks, which is why this is on headings
      and standfirsts rather than on body copy. */
.ink-band h3,
.hub-grid h3,
.solution-card h3,
.section-heading .sub {
  text-wrap: balance;
}

/* 3. Where an anchor lands. Every nav link is a same-page jump and the header
      is fixed, so before this the browser scrolled each target to y=0 and the
      header covered the heading the reader had just asked to see. They arrived
      at a section whose name was hidden behind the thing they clicked it in.
      The extra 24px is breathing room, so the heading arrives just below the
      header rather than welded to its underside.

      Both numbers are measured rather than assumed. The first draft said 64px,
      which is the nav row — but the header is not the nav row: a contact strip
      sits above it, and the rendered heights are 107px and 131px. A 64px
      offset still looked correct on this page only because section padding was
      covering the shortfall, which is precisely the kind of accident that
      breaks the day a section is given less padding. */
:root { --header-h: 107px; }

main > section[id],
#catalogue,
.site-footer[id] {
  scroll-margin-top: calc(var(--header-h) + 24px);
}

/* The strip and the nav sit on separate rows on a phone, so the header is
   taller there and an anchor landing under it is the same bug at a different
   height. */
@media (max-width: 768px) {
  :root { --header-h: 131px; }
}


/* ==========================================================================
   One card family, one rhythm, one focus ring                         (P10)
   ==========================================================================

   1. RADIUS. Measured across the page, the surfaces came back at 26, 12, 999,
      0 and 26 pixels. Three of those are deliberate and two were drift. A pill
      at 999 is a pill because it holds one word; a hub column at 0 is a ruled
      column and not a card at all. But a brand tile at 12 sitting two bands
      away from a solution card at 26 is the same object rounded two different
      ways, and that is the kind of difference a reader cannot name and can
      always feel.

      So radius becomes a scale with a reason rather than a number per
      component: large surfaces that hold a heading and a paragraph, small
      tiles that hold a mark or a label, and pills. A tile keeps a tighter
      radius than a card on purpose — curvature should read as constant around
      a shape, so a small box needs a smaller radius to look equally round —
      but it is now one deliberate step down rather than an unrelated value. */
:root {
  --r-card: 26px;
  --r-tile: 16px;
  --r-pill: 999px;
}

/* 2. GLASS. The brand tiles were mixed at 66% while every other surface on a
      band sat at 76%, so the one grid built entirely of other companies'
      logos was the one letting the field through hardest — behind artwork
      that is nothing but flat colour and fine lettering. */
.ink-band .brand-cloud button {
  /* Was dark glass at 76%, letting the field glow through every logo. The
     owner's ruling: every tile is one clean white plate, because half the
     marks ship with a white background baked in and half ship transparent,
     and only a solid white ground renders the two identically. */
  background: #ffffff;
  border-radius: var(--r-tile);
}

/* 3. RHYTHM. Junction gaps down the page measured 349, 230, 159, 202, 230,
      230, 230, 231, 231 and 141 pixels. The 230s are the page's real rhythm
      and everything else is drift, with two exceptions worth keeping: the
      opening gap under the hero is larger because the hero is the one band
      that should be allowed to end, and the contact band is an inset card
      rather than a band, so it carries its own margin and sits closer.

      The 159 came from a bare `padding-top: 30px` on the requirement bench —
      no comment, no reason findable in the history, and the effect was that
      the bench crowded the band above it while every other section breathed. */
.boq-workbench { padding-top: clamp(56px, 7vw, 104px); }

/* 4. FOCUS. Tabbing the page end to end found 31 distinct controls and five
      with no visible focus state whatsoever: every text input, every select,
      and one button. Not a faint ring — nothing. A keyboard reader filtering
      the catalogue had no way to know which field they were typing into.

      The cause is ordinary and worth naming: buttons and links inherited the
      global focus ring, and form controls did not, because their `:focus`
      rules set a background and a box-shadow and browsers drop the default
      outline the moment an author styles the element at all. The shadow that
      replaced it was `rgba(0, 0, 0, 0)` on the dark header — a focus ring made
      of nothing.

      focus-visible rather than focus, so a pointer user clicking into the
      search box does not get a ring they did not ask for, while a keyboard
      user always does. */
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--brand, var(--green));
  outline-offset: 2px;
}

/* On a dark band the brand green is the wrong ring: it is close in value to
   the ground and disappears at 2px. The headline's own lime carries far more
   luminance and is already the page's accent for "this one". */
.ink-band input:focus-visible,
.ink-band select:focus-visible,
.ink-band textarea:focus-visible,
.ink-band [tabindex]:focus-visible,
.site-header input:focus-visible,
.site-header select:focus-visible {
  outline-color: var(--brand-lime);
}

/* Windows high-contrast mode throws author colours away and keeps the
   outline, so this is the one place a system keyword beats a token. */
@media (forced-colors: active) {
  input:focus-visible,
  select:focus-visible,
  textarea:focus-visible,
  button:focus-visible,
  a:focus-visible { outline: 2px solid Highlight; }
}

/* The hero chips were the last control on the page with no focus state. They
   are the first thing after the search box in the tab order, so a keyboard
   reader met the page's dead spot immediately. They set `outline: solid` with
   a zero width somewhere in their own rules, which reads as styled and paints
   as nothing — the one failure mode a quick look at the source will not catch,
   because the property is present. */
.hero-chips button:focus-visible {
  outline: 2px solid var(--brand-lime);
  outline-offset: 2px;
}


/* ==========================================================================
   The phone, at arm's length                                          (P11)
   ==========================================================================
   Two things, and neither is an accessibility failure. That matters, because
   the temptation was to call them one and inflate the fix.

   The page was walked target by target against WCAG 2.5.8, which asks for
   24x24 CSS px unless a 24px circle centred on the target clears every other
   target's circle. Every control passes, most of them through that spacing
   exception: the footer's brand links are 21px tall and stacked with enough
   room between them that the rule is satisfied. There is no violation here to
   fix, and saying otherwise would be a way of making routine polish sound
   like a bug fix.

   What is true is that a phone is held at arm's length in one hand, and a
   34px chip under a moving thumb is a miss waiting to happen. 44px is Apple's
   number and it is a good one. So this is comfort, stated as comfort.

   The second is type size. A 10.5px availability badge is below what anybody
   reads comfortably on a phone, and these are the labels carrying the least
   redundant information on the card: a part code, a country, whether a line is
   frequently supplied. 12px is the floor. */

@media (max-width: 768px) {
  /* Reach. Padding rather than height where the control is text, so the target
     grows without the type growing with it. */
  .hero-chips button {
    min-height: 44px;
    padding: 0 18px;
  }

  .site-header .header-search input,
  .nav-actions .button,
  .mobile-menu {
    min-height: 44px;
  }

  .mobile-menu { min-width: 44px; }

  /* The in-content controls a reader actually drives the catalogue with: the
     filter drawer and its reset at 32px, and the buttons ending each signal
     card at 31px, which was the smallest anything got on this page.

     Selector depth is not decoration here. The first attempt at this rule was
     written as `.filter-toggle` and lost, because the 32px it was overriding
     is set by `.filters .filter-header button`: three classes and an element
     beat one class, so the shorter selector never applied and the control
     stayed 32px while the source read as though it had been fixed. */
  .filters .filter-header button,
  .signal-actions > button,
  .signal-actions > a,
  .button {
    min-height: 44px;
  }

  .site-header .brand {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  /* Legibility. The floor, not a rescale: everything already at or above 12px
     is left exactly where the type scale put it. */
  /* Written at the depth of the rules they override, which is the whole
     lesson of this block. The first version listed these as bare classes and
     changed nothing: every one of them is sized by a two-class selector
     (`.card-tags .avail`, `.product-card .sku`, `.footer-branches .gst`,
     `.eyebrow.band-number`), so a single class never got near them. The
     stylesheet read as though the floor had been set and the phone still
     rendered 10.5px badges. */
  .card-tags .avail,
  .detail-tags .avail,
  .product-card .sku,
  .footer-branches .gst,
  .eyebrow.band-number,
  .eyebrow,
  .line-label,
  .column-note,
  .signal-tag,
  .compare-title,
  /* The three remaining spans, found by walking the DOM rather than guessing
     at class names: the brand chips inside a category tile, and the field
     labels in the filter drawer and the quote form. A form label at 11.5px is
     the worst of the set, because it is the only thing telling a reader what
     they are about to type into. */
  .cat-brands > span,
  .filter-body label > span,
  .rfq-buyer-grid label > span {
    font-size: 12px;
  }

  /* A band eyebrow is caps with wide tracking, and at 12px that tracking
     starts pulling the words apart. Tightened to match. */
  .eyebrow,
  .eyebrow.band-number { letter-spacing: 0.06em; }
}


/* The desk's own section on the product sheet. It reads as an answer rather
   than as one more outbound link, which is what it was when it sat as the last
   chip in the manufacturer's row. */
.detail-desk .desk-links {
  margin-top: 12px;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
}

.detail-desk .desk-primary {
  background: var(--green, var(--green));
  border-color: transparent;
  color: #ffffff;
  font-weight: 600;
}

.detail-desk .desk-primary:hover {
  background: #0b8a60;
  color: #ffffff;
}


/* ---------- pick up where you left off ---------- */

/* The reader's own history, offered back as a quiet strip above the grid. It
   competes with nothing: one muted label, part codes as chips, and it is not
   there at all until this browser has opened at least two sheets. */
.recent-strip {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 18px;
}

.recent-strip .recent-label {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--faint);
  margin-right: 4px;
}

.recent-strip a {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: #ffffff;
  color: var(--ink-2);
  font-family: var(--mono, monospace);
  font-size: 13px;
  transition: border-color 180ms var(--ease), color 180ms var(--ease);
}

.recent-strip a:hover,
.recent-strip a:focus-visible {
  border-color: var(--green, var(--green));
  color: var(--ink);
}

/* Long product names still make usable chips. */
.recent-strip a { max-width: 240px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: inline-block; line-height: 32px; }

/* ---------- brand and range pages ----------
   Server-rendered in the note page's language; these rules are only the parts
   a note does not have. The family list is the page's substance and its
   internal links, so it reads as content, not as tags. */

.landing-sheet { max-width: 760px; }

.landing-families { margin-top: clamp(30px, 4vw, 44px); }

.landing-families h2 {
  margin: 26px 0 12px;
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: rgba(233, 240, 250, 0.5);
}

.landing-families ul {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.landing-families li a {
  display: inline-flex;
  align-items: center;
  min-height: 34px;
  padding: 5px 13px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  background: rgba(255, 255, 255, 0.045);
  font-size: 14px;
  color: rgba(233, 240, 250, 0.88);
  transition: border-color 180ms var(--ease), background 180ms var(--ease);
}

.landing-families li a:hover {
  border-color: rgba(184, 231, 106, 0.5);
  background: rgba(184, 231, 106, 0.1);
  color: #fff;
}

.landing-more {
  margin: 12px 0 0;
  font-size: 13px;
  color: rgba(233, 240, 250, 0.55);
}

/* The brand index: a name and its one-line identity, as a column. */
.landing-brandlist ul { flex-direction: column; gap: 6px; }

.landing-brandlist li a {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  width: 100%;
  min-height: 44px;
  padding: 10px 16px;
  border-radius: var(--r-md);
}

.landing-brandlist b { font-size: 16px; color: #fff; }
.landing-brandlist span { font-size: 13px; color: rgba(233, 240, 250, 0.55); }


.whatsapp-float span { display: none; }


/* ---------- questions buyers ask ----------
   Native details/summary: crawlable text with zero script, which is the whole
   point of the section. The open state is the reader's, not an animation's. */
.faq-band { display: block; }
.faq-head { max-width: 62ch; margin-bottom: 22px; }
.faq-list { max-width: 74ch; display: grid; gap: 10px; }
.faq-list details {
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 12px;
  padding: 2px 18px;
}
.faq-list summary {
  cursor: pointer;
  padding: 14px 0;
  font-weight: 600;
  font-size: 16px;
  list-style-position: inside;
}
.faq-list summary:focus-visible { outline: 2px solid var(--lime, #b8e76a); outline-offset: 2px; }
.faq-list details > p { margin: 0 0 16px; color: #c7d2e4; line-height: 1.55; font-size: 15px; }

/* The hero names its city in one quiet line under the lede. */
.hero-place { margin: 10px 0 0; font-size: 15px; color: rgba(219, 229, 244, 0.78); max-width: 52ch; }

/* The landing pages' one-line letterhead. */
.note-contact { margin: 26px 0 0; font-size: 13.5px; color: rgba(219, 229, 244, 0.7); line-height: 1.6; }
.note-contact a { color: inherit; }

/* ---------- the requirement matcher's screenshot, and why a row was chosen ---------- */

/* Bounded on screen: a phone screenshot is tall enough to push the Match
   button out of reach on a handset, and the reader has already seen the
   picture they just chose. */
.boq-shot {
  margin: 12px 0 0;
  display: grid;
  gap: 8px;
}

.boq-shot img {
  display: block;
  max-width: 100%;
  max-height: 180px;
  width: auto;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.16);
}

.boq-shot figcaption {
  font-size: 12px;
  line-height: 1.6;
  color: rgba(233, 240, 250, 0.7);
}

.boq-shot figcaption span { display: block; font-family: var(--mono); color: #ffffff; }

.boq-shot figcaption button {
  border: 0;
  background: none;
  padding: 0;
  margin-left: 6px;
  color: var(--brand-lime, #b8e76a);
  font: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

/* Why this row and not another. The owner's instruction is that his own
   two-year sellers are offered first, and a recommendation that cannot say
   where it came from is a recommendation nobody trusts. */
.boq-why {
  display: inline-block;
  margin: 2px 0 4px;
  padding: 2px 8px;
  border-radius: 999px;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: #d9f99d;
  background: rgba(184, 231, 106, 0.14);
  border: 1px solid rgba(184, 231, 106, 0.34);
}

/* The other makes, when the line named none. */
.boq-alts {
  margin-top: 8px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: rgba(233, 240, 250, 0.62);
}

.boq-alts button {
  padding: 4px 10px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.05);
  color: rgba(240, 246, 255, 0.92);
  font: inherit;
  cursor: pointer;
}

.boq-alts button:hover {
  border-color: rgba(184, 231, 106, 0.6);
  background: rgba(184, 231, 106, 0.12);
}


/* ==========================================================================
   Isolation mode: /?fieldOnly=1                                       (P13)
   ==========================================================================
   The field on bare ground with the whole document hidden, so the canvas can
   be measured without the first viewport's shell around it.

   This exists because "the hero feels less smooth than lower down" has two
   completely different causes and no way to tell them apart by looking. If
   the field alone paces the same at the top of the page as it does anywhere
   else, the canvas is not the thing; whatever is different about the hero is
   in the hero. If it is slower at the top even alone, the loop is.

   Hidden with `visibility` and not `display`, deliberately. Setting
   `display:none` on the page would drop every element out of layout, which
   changes document height, which changes what a scroll test can even do, and
   it also throws away exactly the layer count the comparison is about.
   `visibility: hidden` keeps the boxes and stops the paint, which isolates
   the field's own cost rather than inventing a different page.

   The HUD is exempt, or the readout disappears with everything else. */
.field-only body > *:not(.page-field):not(#fieldHud) {
  visibility: hidden !important;
}

.field-only body {
  background: #05070c !important;
  /* No scrollbar: the document keeps its height, so without this the
     comparison run scrolls a page that is entirely invisible. */
  overflow: hidden !important;
}

.field-only .page-field {
  visibility: visible !important;
}


/* ==========================================================================
   The reading, before it becomes a requirement                        (P14)
   ==========================================================================
   Recognised lines used to be appended into the requirement box and matched
   on the spot, so a misread part code became a confident product
   recommendation with no step in between where anyone could disagree with it.
   This panel is that step. It is styled as a quotation rather than as an
   input: the reader is reporting what it thinks it saw, and the person is
   the one who decides. */
.ocr-review {
  margin-top: 16px;
  padding: 16px 18px 18px;
  border: 1px solid rgba(184, 231, 106, 0.28);
  border-radius: 16px;
  background: rgba(7, 12, 22, 0.6);
  display: grid;
  gap: 10px;
}

.ocr-review header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}

.ocr-review h4 {
  margin: 0;
  font-size: 15px;
  color: #ffffff;
}

.ocr-confidence {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.02em;
  padding: 3px 10px;
  border-radius: 999px;
  background: rgba(184, 231, 106, 0.16);
  color: var(--brand-lime);
  white-space: nowrap;
}

/* A poor reading says so in a colour rather than only in a sentence, because
   the sentence is the thing a hurried person skips. Amber and not red: the
   lines may well be right, and the screenshot reaches the desk either way, so
   this is a caution and not a failure. */
.ocr-confidence.is-low {
  background: rgba(251, 191, 36, 0.18);
  color: #fcd34d;
}

.ocr-note {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: rgba(233, 240, 250, 0.72);
}

.ocr-review textarea {
  width: 100%;
  min-height: 130px;
  resize: vertical;
  padding: 12px 14px;
  border-radius: 12px;
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: rgba(0, 0, 0, 0.28);
  color: rgba(240, 246, 255, 0.94);
  /* Monospace here and nowhere else in this panel. These are part codes being
     proofread against a picture, and O against 0 is the whole job. */
  font: 13px/1.6 var(--mono);
}

.ocr-review textarea:focus-visible {
  outline: 2px solid var(--brand-lime);
  outline-offset: 2px;
}

.ocr-review-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

@media (max-width: 620px) {
  .ocr-review { padding: 14px; }
  .ocr-review-actions .button { flex: 1 1 100%; justify-content: center; }
}
