/*
 * murmtools.com — the Scoreboard system, on the web.
 *
 * This file replaces fifteen pages of duplicated inline <style> blocks, each of
 * which carried the RETIRED family violet (#4A3F8F) and a system font stack.
 * The site was a generic SaaS template describing products that had moved on
 * without it.
 *
 * ── THE RULE THE WHOLE SITE OBEYS ──────────────────────────────────────────
 * These extensions exist to mark every figure with how it came to be known.
 * A site that argues for that and then prints unmarked marketing numbers is
 * arguing against itself. So the vocabulary is real page furniture here:
 *
 *   .fig--counted    solid ink fill      we counted it, and you can check
 *   .fig--computed   outline, no fill    a formula produced it; the rule is printed
 *   .fig--refused    1px dashed + em-dash no such number exists and none can
 *   .fig--withheld   45° hatch           it exists and is not ours to publish
 *
 * Colour carries NONE of that — fill, outline, dash and hatch are shapes, and
 * the display face is reserved for counted figures. Both channels survive a
 * greyscale print and every colour-vision deficiency, which is the same claim
 * the products make about themselves.
 *
 * Blue is the one hue that is never a value: it marks a control you can operate.
 * The three product accents (coral / ink / violet) are identity, not meaning,
 * and each matches that tool's own Web Store tile.
 */

/* ── Faces. Local, subset, never a CDN — a company selling privacy tools does
      not fetch its own typeface from a third party.

      THREE cuts, not four. The 500 cut was 15,896 B, and Chrome only fetches a
      face some glyph actually needs — put it back and instrument the wire and
      exactly two of the 144 pages here pull it: index.html and consent-gap.html,
      the only two with a header nav. It was buying a five-item bar and the bold
      words in one paragraph. Both now ask for 600, which those same two pages
      already download for their headings and buttons, so the byte is paid once
      instead of twice. Nothing else in this file requests weight 500 — if you
      add a rule that does, the browser will synthesise it from 400 rather than
      tell you, so add the cut back with it.
      NOTE: this is scoped to the SITE. Weight 500 is load-bearing inside the
      extensions (murmstack/src/ui/scoreboard.css and siblings) and is asserted
      by scoreboard-guardrails.test.ts — do not follow this deletion there. ── */
@font-face {
  font-family: 'Murm Display';
  font-weight: 700;
  font-display: swap;
  src: url('fonts/space-grotesk-700.woff2') format('woff2');
}
@font-face {
  font-family: 'Murm Sans';
  font-weight: 400;
  font-display: swap;
  src: url('fonts/instrument-sans-400.woff2') format('woff2');
}
@font-face {
  font-family: 'Murm Sans';
  font-weight: 600;
  font-display: swap;
  src: url('fonts/instrument-sans-600.woff2') format('woff2');
}
/* ── The metric-matched fallback that stops the swap reflowing the page.
      MEASURED, not guessed: at 100px the same string sets 2641.8px in
      Instrument Sans and 2352.3px in -apple-system — 12.3% narrower, which is
      why /ad-library-tools shifted 0.272 CLS at 146ms when the real face
      landed. Arial is the far closer base at 2562.7px, so the fallback is
      Arial scaled by 2641.8/2562.7 = 103.1%. This face is never chosen when
      the real one loads; it only holds the layout for the first paint.

      The vertical overrides are READ FROM THE FONT, not guessed: Instrument
      Sans is upm=1000, hhea ascent 970, descent -250, lineGap 0 — so 97% / 25%
      / 0%. A first pass used 92%/24% from estimation and left CLS uneven across
      pages, because matching width without matching line box still reflows. ── */
@font-face {
  font-family: 'Murm Sans Fallback';
  src: local('Arial'), local('Helvetica Neue'), local('Liberation Sans');
  size-adjust: 103.1%;
  ascent-override: 97%;
  descent-override: 25%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'Murm Mono';
  font-weight: 400;
  font-display: swap;
  src: url('fonts/dm-mono-400.woff2') format('woff2');
}

:root {
  /* Scoreboard light. Every ratio measured, not assumed. */
  --paper: #ffffff;
  --card: #f3f4f7;
  --ink: #14161b;    /* 18.10:1 on paper */
  --ink-2: #4c5260;  /* 7.83:1 */
  --rule: #e7e9ef;
  /* Was #8e96a6, whose comment claimed 3.06:1. Recomputed against WCAG 2.x
     relative luminance it is 2.97:1 on --paper — under 3.0, and this token is
     the sole carrier of "refused": .fig--refused .edge rect (below) strokes
     the dashed box with it, and with JS on that stroke is the ONLY thing
     drawing the tile's edge, because .js .fig--refused zeroes the CSS border.
     #858da0 measures 3.33:1 on the same ground. Dark is untouched — #5b6474
     is a different problem on a different paper and was not in question. */
  --mark: #858da0;   /* 3.33:1 — structural lines only, never text */
  --live: #2c5cff;   /* 5.14:1 — a control you can operate, never a value */
  --live-soft: #edf1ff;
  --on-ink: #b7bdc9; /* 9.60:1 on the ink fill */

  /* Product identity. Each is that tool's own store-tile ground. */
  --stack: #ff5c39;
  --spy: #14161b;
  --ads: #4a3f8f;

  --display: 'Murm Display', 'Space Grotesk', system-ui, sans-serif;
  --sans: 'Murm Sans', 'Instrument Sans', 'Murm Sans Fallback', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
  --mono: 'Murm Mono', 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  --gutter: clamp(20px, 5vw, 34px);
  --r-block: 14px;
  --r-tile: 12px;
  --r-chip: 999px;
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);

  /* ── Sfeerlaag: the studio (Meetblad shelf). Backgrounds and shadows ONLY —
        no text ever takes these tokens. The plinths are a lit product tent:
        white falling to a 2% warm grey, plus one large low shadow. They stay
        lit in dark mode on purpose — an instrument photographed in a light
        tent does not change under a dark reading lamp — and that is safe
        because no token-coloured text sits on a plinth at any breakpoint:
        the captures are images, every label lives below on --paper. */
  --studio-hi: #ffffff;
  --studio-lo: #f7f6f3;
  --shadow-obj: 0 30px 50px -28px rgba(20, 22, 27, 0.28),
    0 2px 6px rgba(20, 22, 27, 0.05);
}

@media (prefers-color-scheme: dark) {
  :root {
    --paper: #0f1116;
    --card: #171a21;
    --ink: #f2f4f8;
    --ink-2: #a2a9b8;
    --rule: #242833;
    --mark: #5b6474;
    --live: #7c9bff; /* 7.12:1 on the dark ground */
    --live-soft: #1a2136;
    /* Runs backwards on purpose: the ink fill inverts to near-white, so its
       secondary label must go dark. The light value here measures 1.95:1. */
    --on-ink: #4c5260;
    --stack: #ff7a5c;
    --ads: #9a8cff;
    --spy: #f2f4f8;
    /* Studio tokens are NOT overridden: the plinth keeps its fixed light
       (see :root). Only the shadow deepens, because a light object floating
       on a dark ground needs a darker penumbra to read as standing. */
    --shadow-obj: 0 30px 50px -26px rgba(0, 0, 0, 0.6),
      0 2px 6px rgba(0, 0, 0, 0.3);
  }
}

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font: 400 17px/1.6 var(--sans);
  font-variant-numeric: tabular-nums;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

.wrap {
  max-width: 1080px;
  margin: 0 auto;
  padding: 0 var(--gutter);
}
.wrap--narrow {
  max-width: 760px;
}

a {
  color: var(--live);
  text-decoration: none;
}
a:hover {
  text-decoration: underline;
}

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

/* ── Header ── */
.hdr {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 22px 0;
  position: sticky;
  top: 0;
  z-index: 20;
  background: color-mix(in srgb, var(--paper) 88%, transparent);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid transparent;
  transition: border-color 200ms var(--ease);
}
.hdr.stuck {
  border-bottom-color: var(--rule);
}
.hdr-mark {
  width: 30px;
  height: 30px;
  border-radius: 8px;
  background: var(--ink);
  color: var(--paper);
  display: grid;
  place-items: center;
  font: 700 17px/1 var(--display);
  flex: none;
}
.hdr-name {
  font: 700 19px/1 var(--display);
  letter-spacing: -0.02em;
}
.hdr-nav {
  margin-left: auto;
  display: flex;
  gap: 22px;
  font-size: 15px;
  /* 600, not 500: the 500 cut is gone (see the @font-face block). 600 is the
     weight this bar already sat beside in .hdr-name, so it reads as the same
     bar rather than a heavier one. */
  font-weight: 600;
}
.hdr-nav a {
  color: var(--ink-2);
}
.hdr-nav a:hover {
  color: var(--ink);
  text-decoration: none;
}
@media (max-width: 620px) {
  .hdr-nav {
    display: none;
  }
}

/* ── Hero ── */
.hero {
  padding: clamp(56px, 11vw, 110px) 0 clamp(36px, 6vw, 64px);
}
.hero h1 {
  font: 700 clamp(40px, 8.5vw, 84px) / 0.98 var(--display);
  letter-spacing: -0.04em;
  margin: 0 0 22px;
  max-width: 15ch;
}
.hero .lede {
  font-size: clamp(18px, 2.2vw, 21px);
  line-height: 1.5;
  color: var(--ink-2);
  margin: 0;
  max-width: 56ch;
}
.hero .lede b {
  color: var(--ink);
  /* Also 600. Left at 500 this would have asked for a face that no longer
     exists and been synthesised from 400 — the emphasis would have quietly
     stopped being emphasis. */
  font-weight: 600;
}

/* ── Figures: the four provenance states as real furniture ── */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(228px, 1fr));
  gap: 14px;
}

.fig {
  border-radius: var(--r-tile);
  padding: 20px 20px 18px;
  min-height: 168px;
  display: flex;
  flex-direction: column;
}
.fig-n {
  font: 700 clamp(38px, 5vw, 52px) / 0.9 var(--display);
  letter-spacing: -0.04em;
  display: block;
}
.fig-l {
  margin-top: 10px;
  font-size: 15px;
  font-weight: 600;
}
.fig-note {
  margin-top: 7px;
  font-size: 13.5px;
  line-height: 1.45;
  color: var(--ink-2);
}

/* We counted it. */
.fig--counted {
  background: var(--ink);
  color: var(--paper);
}
.fig--counted .fig-note {
  color: var(--on-ink);
}

/* A formula produced it — outlined, never filled, because an estimate that
   fills a block starts arguing at the volume of a fact. */
.fig--computed {
  border: 1px solid var(--ink);
}
.fig--computed .fig-n {
  font-family: var(--sans);
  font-weight: 600;
  letter-spacing: -0.02em;
}

/* No such number exists and none can. Same slot, same scale — refusing to
   guess is a result, so it is drawn at the size of a finding. */
.fig--refused {
  border: 1px dashed var(--mark);
}
.fig--refused .fig-n {
  color: var(--ink-2);
}

/* It exists and is not ours to publish.
   The hatch draws in --mark, not --rule. It used to draw in --rule, which
   measures 1.21:1 on light paper and 1.28:1 on dark — a meaning-carrying mark
   that was for practical purposes invisible, i.e. "withheld" degrading to
   "blank tile". --mark measures 3.33:1 (light) / 3.17:1 (dark) on --paper,
   both over the 3:1 WCAG floor for meaning-bearing graphics. Text over the
   hatch stays legible where it matters: --ink (the figure and its label)
   measures 5.44:1 (light) / 5.42:1 (dark) against the hatch line itself. The
   --ink-2 note measures 2.35:1 against the line — accepted, because the line
   is 1.5px in a 6px period (25% cover) and the note's dominant ground remains
   --paper at 7.83:1; a tile that carries a long note should still prefer the
   counted or refused form. The border stays --rule: it is tile chrome, shared
   with .leg — the hatch alone carries the meaning. */
.fig--withheld {
  border: 1px solid var(--rule);
  background-image: repeating-linear-gradient(
    45deg,
    var(--mark) 0 1.5px,
    transparent 1.5px 6px
  );
}

/* ── Product cards ── */
.tools {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 16px;
}
.tool {
  --accent: var(--ink);
  position: relative;
  border-radius: var(--r-block);
  border: 1px solid var(--rule);
  padding: 26px 24px 24px;
  overflow: hidden;
  transition: transform 200ms var(--ease), border-color 200ms var(--ease);
}
.tool::before {
  content: '';
  position: absolute;
  inset: 0 0 auto;
  height: 4px;
  background: var(--accent);
}
.tool:hover {
  transform: translateY(-3px);
  border-color: var(--mark);
}
.tool--stack {
  --accent: var(--stack);
}
.tool--spy {
  --accent: var(--spy);
}
.tool--ads {
  --accent: var(--ads);
}
.tool h3 {
  font: 700 23px/1.1 var(--display);
  letter-spacing: -0.02em;
  margin: 4px 0 6px;
}
.tool .role {
  font: 400 12px/1 var(--mono);
  color: var(--ink-2);
  text-transform: lowercase;
}
.tool p {
  color: var(--ink-2);
  font-size: 15px;
  margin: 12px 0 18px;
}
.tool ul {
  list-style: none;
  margin: 0 0 20px;
  padding: 0;
}
.tool li {
  font-size: 14.5px;
  padding: 7px 0;
  border-bottom: 1px solid var(--rule);
  display: flex;
  gap: 10px;
  align-items: baseline;
}
.tool li:last-child {
  border-bottom: 0;
}
.tool li b {
  font: 700 15px/1 var(--display);
  margin-left: auto;
  flex: none;
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 11px 18px;
  border-radius: 10px;
  background: var(--ink);
  color: var(--paper);
  font-weight: 600;
  font-size: 15px;
  transition: transform 140ms var(--ease);
}
.btn:hover {
  text-decoration: none;
  transform: translateY(-1px);
}
.btn--ghost {
  background: none;
  color: var(--ink);
  border: 1px solid var(--mark);
}
/* `--live` INVERTS: #2c5cff in light, #7c9bff in dark. A hardcoded white label
   measured 5.14:1 light but 2.63:1 dark — the same class of bug as `--on-ink`,
   a token whose text colour has to invert with its ground. `--paper` already
   runs backwards with the scheme, so it clears both: 5.14:1 and 7.18:1. */
.btn--free {
  background: var(--live);
  color: var(--paper);
}

/* ── Sections ── */
.sec {
  padding: clamp(48px, 8vw, 88px) 0;
  border-top: 1px solid var(--rule);
}
.sec h2 {
  font: 700 clamp(26px, 4vw, 38px) / 1.1 var(--display);
  letter-spacing: -0.03em;
  margin: 0 0 12px;
  max-width: 20ch;
}
.sec .sub {
  color: var(--ink-2);
  font-size: 17px;
  margin: 0 0 30px;
  max-width: 58ch;
}
.kicker {
  font: 400 12px/1 var(--mono);
  color: var(--ink-2);
  margin: 0 0 14px;
}

/* ── The vocabulary legend ── */
.legend {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  gap: 12px;
  margin-top: 26px;
}
.leg {
  padding: 16px;
  border-radius: var(--r-tile);
  border: 1px solid var(--rule);
}
.leg-sw {
  height: 42px;
  border-radius: 8px;
  margin-bottom: 12px;
}
.leg-sw--counted {
  background: var(--ink);
}
.leg-sw--computed {
  border: 1px solid var(--ink);
}
.leg-sw--refused {
  border: 1px dashed var(--mark);
}
/* Same repair as .fig--withheld: the hatch is the legend's whole payload, so
   drawing it at 1.21:1 taught the vocabulary with an empty swatch. */
.leg-sw--withheld {
  border: 1px solid var(--rule);
  background-image: repeating-linear-gradient(
    45deg,
    var(--mark) 0 1.5px,
    transparent 1.5px 6px
  );
}
.leg b {
  display: block;
  font-size: 14.5px;
  margin-bottom: 4px;
}
.leg span {
  font-size: 13.5px;
  line-height: 1.45;
  color: var(--ink-2);
}

/* ── Prose (privacy pages, articles) ── */
.prose h1 {
  font: 700 clamp(30px, 5vw, 44px) / 1.08 var(--display);
  letter-spacing: -0.03em;
  margin: 44px 0 10px;
}
.prose h2 {
  font: 600 20px/1.3 var(--sans);
  margin: 34px 0 10px;
}
.prose p,
.prose li {
  color: var(--ink-2);
  font-size: 16.5px;
}
.prose b {
  color: var(--ink);
  font-weight: 600;
}
.prose code,
.mono {
  font: 400 14px var(--mono);
  background: var(--card);
  border-radius: 5px;
  padding: 1px 6px;
}
/* A hostname inside <code> has no break opportunity a browser will take on its
   own: `web.facebook.com/ads/library` measured 287px on murmads-privacy.html,
   which overflowed a 320px viewport and scrolled the whole document
   (docScrollWidth 347 vs clientWidth 320).

   The selector is the bare element, not `.prose code`: the four privacy pages
   carry no `.prose` wrapper, so a scoped rule missed the only page that was
   actually broken. `.mono` is deliberately NOT included — it also dresses
   figures and counters in tiles, where `anywhere` would break a number in two. */
code {
  overflow-wrap: anywhere;
}
.prose table {
  border-collapse: collapse;
  width: 100%;
  font-size: 15px;
}
.prose th,
.prose td {
  text-align: left;
  padding: 10px 12px;
  border: 1px solid var(--rule);
  vertical-align: top;
}
.prose th {
  background: var(--card);
  font-weight: 600;
}
.prose td {
  color: var(--ink-2);
}

/* ── Footer ── */
.ftr {
  border-top: 1px solid var(--rule);
  padding: 34px 0 56px;
  color: var(--ink-2);
  font-size: 14.5px;
}
.ftr-cols {
  display: flex;
  flex-wrap: wrap;
  gap: 30px 56px;
  margin-bottom: 26px;
}
.ftr-col b {
  display: block;
  color: var(--ink);
  font-size: 13px;
  margin-bottom: 10px;
}
.ftr-col a {
  display: block;
  color: var(--ink-2);
  padding: 4px 0;
}
.ftr-col a:hover {
  color: var(--live);
}
.ftr-legal {
  border-top: 1px solid var(--rule);
  padding-top: 20px;
  font-size: 13px;
  display: flex;
  flex-wrap: wrap;
  gap: 8px 18px;
  align-items: center;
}
.ftr-legal .sp {
  flex: 1;
}

/* ── Motion. One rise, staggered, once. ── */
@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(14px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
.rise {
  animation: rise 620ms var(--ease) both;
}
.rise:nth-child(2) {
  animation-delay: 70ms;
}
.rise:nth-child(3) {
  animation-delay: 140ms;
}
.rise:nth-child(4) {
  animation-delay: 210ms;
}
.rise:nth-child(5) {
  animation-delay: 280ms;
}
.rise:nth-child(6) {
  animation-delay: 350ms;
}

/* Ship it. The whole sequence collapses and nothing is left invisible. */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-delay: 0ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   CONTENT PAGES
   Fourteen pages carried their own inline <style>, each a near-copy of the
   next and each declaring the retired violet. The class names below are the
   ones those pages already use, so the markup did not have to be rewritten —
   only the stylesheet they point at. Old tokens map as:
     --text → --ink · --text2 → --ink-2 · --text3 → --ink-2 (never --mark,
     which is 3.06:1 and must not carry text) · --border → --rule
     --subtle → --card · --primary/--accent → --live
   ═══════════════════════════════════════════════════════════════════════════ */

.crumb {
  font: 400 13px/1 var(--mono);
  color: var(--ink-2);
  padding-top: 20px;
}
.crumb a {
  color: var(--ink-2);
}
.crumb a:hover {
  color: var(--live);
}

.eyebrow,
.kicker {
  font: 400 12px/1 var(--mono);
  color: var(--ink-2);
  margin: 0 0 14px;
  /* The old rule set `letter-spacing:.08em; text-transform:uppercase` on a
     700 weight in the accent hue — four devices shouting one label. A mono
     face already reads as machine furniture; it needs none of them. */
}

h1 {
  font: 700 clamp(32px, 5.5vw, 52px) / 1.06 var(--display);
  letter-spacing: -0.035em;
  margin: 26px 0 14px;
  max-width: 20ch;
}
h2 {
  font: 700 clamp(22px, 3vw, 30px) / 1.15 var(--display);
  letter-spacing: -0.025em;
  margin: 40px 0 12px;
}
h3 {
  font: 600 18px/1.3 var(--sans);
  margin: 0 0 8px;
}

.standfirst,
.lede {
  font-size: 19px;
  line-height: 1.55;
  color: var(--ink-2);
  margin: 0 0 10px;
  max-width: 62ch;
}
.byline,
.meta,
.updated,
.note {
  font: 400 13px/1.5 var(--mono);
  color: var(--ink-2);
  margin: 16px 0 0;
}

p,
li {
  max-width: 68ch;
}

/* Cards, callouts, findings — one construction, three names, because the
   pages already use all three and the markup is not being rewritten. */
.card,
.finding,
.cta {
  border: 1px solid var(--rule);
  border-radius: var(--r-block);
  padding: 20px 22px;
  margin: 22px 0;
  background: var(--card);
}
.cta {
  margin: 40px 0 12px;
  padding: 26px;
}
.cta h2 {
  margin: 0 0 8px;
  font-size: 22px;
}
.cta p {
  margin: 0 0 14px;
  color: var(--ink-2);
}
.card p:last-child,
.finding p:last-child,
.cta p:last-child {
  margin-bottom: 0;
}

/* A callout is a card that leans on one edge. The edge is --live because it
   is chrome; it says "read this bit", not "this figure is different". */
.callout {
  border: 1px solid var(--rule);
  border-left: 3px solid var(--live);
  border-radius: var(--r-tile);
  padding: 16px 18px;
  margin: 22px 0;
  background: var(--card);
}
.callout p {
  margin: 0;
  font-size: 15.5px;
}

.tag {
  display: inline-block;
  font: 400 11.5px/1 var(--mono);
  color: var(--ink-2);
  border: 1px solid var(--rule);
  border-radius: var(--r-chip);
  padding: 5px 10px;
  margin-bottom: 10px;
}

.verdict {
  display: grid;
  gap: 14px;
  margin: 26px 0;
}

/* Comparison tables. These carry yes/no cells across five vs/ pages, and the
   glyph does the work — a green tick on a red cross is the one encoding that
   fails for the readers most likely to need it. */
.tablewrap {
  overflow-x: auto;
  margin: 24px 0;
  border-radius: var(--r-tile);
}
table {
  border-collapse: collapse;
  width: 100%;
  font-size: 15px;
  min-width: 520px;
}
th,
td {
  text-align: left;
  padding: 11px 13px;
  border: 1px solid var(--rule);
  vertical-align: top;
}
th {
  background: var(--card);
  font-weight: 600;
  font-size: 14px;
}
td {
  color: var(--ink-2);
}
.yes,
.no,
.ok,
.warn {
  font-weight: 600;
  color: var(--ink);
}
.yes::before,
.ok::before {
  content: '✓ ';
}
.no::before,
.warn::before {
  content: '✗ ';
  color: var(--ink-2);
}

/* Numbered steps — welcome and uninstall both use them, at two densities. */
.steps {
  counter-reset: s;
  list-style: none;
  padding: 0;
  margin: 30px 0 8px;
  display: grid;
  gap: 12px;
}
.steps li {
  counter-increment: s;
  position: relative;
  padding: 17px 20px 17px 60px;
  border: 1px solid var(--rule);
  border-radius: var(--r-tile);
  max-width: none;
}
.steps li::before {
  content: counter(s);
  position: absolute;
  left: 18px;
  top: 15px;
  width: 28px;
  height: 28px;
  border-radius: 8px;
  background: var(--ink);
  color: var(--paper);
  display: grid;
  place-items: center;
  font: 700 14px/1 var(--display);
}
/* h2 AND h3 on purpose. welcome.html's steps currently jump h1 → h3, and that
   is being corrected to h2 in its own deploy. Matching both means this file
   ships on its own schedule instead of being pinned to that one — and if a
   future step list picks either level, it is already styled. */
.steps h2,
.steps h3 {
  margin: 0 0 3px;
  font-size: 16px;
}
.steps p {
  margin: 0;
  color: var(--ink-2);
  font-size: 14.5px;
}

.reasons {
  display: grid;
  gap: 10px;
  margin: 30px 0 8px;
}
.reasons a {
  display: block;
  padding: 15px 18px;
  border: 1px solid var(--rule);
  border-radius: var(--r-tile);
  color: var(--ink);
  font-weight: 600;
  transition: border-color 160ms var(--ease), background 160ms var(--ease);
}
.reasons a:hover {
  border-color: var(--live);
  background: var(--live-soft);
  text-decoration: none;
}
.reasons a span {
  display: block;
  font-size: 13.5px;
  font-weight: 400;
  color: var(--ink-2);
  margin-top: 3px;
}

.kbd {
  font: 400 13px var(--mono);
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: 5px;
  padding: 2px 7px;
}

.faq {
  margin-top: 44px;
}
.faq details {
  border-bottom: 1px solid var(--rule);
  padding: 15px 0;
}
.faq summary {
  cursor: pointer;
  font-weight: 600;
  font-size: 16.5px;
}
.faq summary::marker {
  color: var(--ink-2);
}
.faq p {
  margin: 10px 0 2px;
  color: var(--ink-2);
  font-size: 15.5px;
}

/* Legacy chrome from the old pages. `.mark` and `.brand` are the header the
   new .hdr replaces; they stay so an unconverted page still renders. */
.mark {
  display: inline-grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 8px;
  background: var(--ink);
  color: var(--paper);
  font: 700 17px/1 var(--display);
}
.brand {
  font: 700 19px/1 var(--display);
  letter-spacing: -0.02em;
}
.sp {
  flex: 1;
}
.foot {
  border-top: 1px solid var(--rule);
  margin-top: 48px;
  padding: 26px 0 56px;
  color: var(--ink-2);
  font-size: 14px;
}

/* The fourteen content pages carry `<header><span class="mark">…` rather than
   the homepage's `.hdr`. Their flex layout lived in the inline <style> that was
   deleted, so without this rule the mark and the wordmark butt together and the
   page's first heading collapses under them. Styling the bare element means the
   existing markup did not have to be touched on any of them. */
header {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 22px 0 6px;
}
header .brand {
  line-height: 1;
}

/* An eyebrow directly after the header was inheriting the header's own bottom
   spacing and sitting on top of it. */
header + article > .eyebrow,
header + .eyebrow {
  margin-top: 22px;
}

/* `.byline` sat 16px under a 19px standfirst and 0px above the next paragraph,
   so it read as a caption for the body text rather than for the headline. */
.byline {
  margin: 14px 0 26px;
}

/* Content-page headlines run long — a book title, a comparison, a question.
   20ch orphaned the last word on nearly all of them. */
h1 {
  max-width: 26ch;
}
.hero h1 {
  max-width: 15ch;
}

/* ════════════════════════════════════════════════════════════════════════════
   MOTION LAYER
   Built from ~/dev-library/animations.md, by name, with its caveats honoured.
   Every technique here is named in the comment above it so it can be looked up.

   THE ONE RULE THAT OVERRIDES ALL OF THEM: no figure moves. Ever. A number
   carries its provenance in its shape — fill, outline, dash, hatch — and a
   value that rolls or counts up is a value the reader is asked to trust
   mid-tween. The count-up that used to live here defended itself as "watching
   the counting happen", but it was easeOutExpo over 1200ms: a performance of
   counting, not counting. It is removed from motion.js in this same redesign,
   and the tiles below are styled so the true value simply stands there from
   first paint. Only non-numeric shapes may move: a line drawing itself, a
   block arriving, an edge tracing its own perimeter.
   ════════════════════════════════════════════════════════════════════════════ */

/* Belt-and-braces for that rule: even if a future entrance class lands on the
   wrong node, the figure itself cannot be animated without deleting this. The
   BLOCK a figure sits in may still enter (opacity, below); the glyphs never
   move relative to it. */
.fig-n,
.tool li b,
.inst li b,
.anno-n,
.anno-big {
  animation: none !important;
  transition: none !important;
}

/* ── The `.js`/.fonts-in opacity gates are GONE, and so is the settle guard
      they existed to serve. `.js .anim { opacity: 0 }` meant motion.js OWNED
      first paint: a stalled script, a blocked request or a crawler that never
      scrolls left regions of the page invisible, and the whole fonts-in dance
      existed to time reveals that no longer happen. Every entrance below is
      "@starting-style entrance (zero-JS)": the base value IS the end state,
      the hidden state exists only inside @starting-style inside a
      no-preference media query — so no JS, no font event and no scroll can
      strand content at opacity 0. The no-JS-first guarantee the earlier
      measurement confirmed now holds identically WITH JS. ── */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* ── The line-mask headline rise is dead, not converted. The caveat on the
      @starting-style entrance is "never on the H1" — the H1 is the LCP element
      at desktop widths, and the old split (`.ln > i` at translateY(110%),
      opacity 0) hid it behind a JS split plus a font event. motion.js still
      performs the split until its own partition ships, so `.ln` stays as an
      inert structural shell: base fully visible, zero motion, and the sr-only
      copy keeps reading correctly either way. ── */
.ln {
  display: block;
}
.ln > i {
  display: block;
  font-style: normal;
}

/* ── The blur-to-focus lede is gone with the gates. Its own comment already
      conceded the point: the text that arrived early arrived under blur(6px) —
      ink and a settling shape, not words — and full legibility was governed by
      the blur clearing at 1,954ms p50. Under "the page is a measuring
      instrument" a paragraph that withholds itself for two seconds is the
      instrument fogging its own glass. The lede now enters like every other
      block: opacity only, base visible, no JS in the loop. ── */

/* ── "@starting-style entrance (zero-JS)" (S). Replaces the old
      IntersectionObserver + `.js .stag > *` opacity gate. The technique's own
      caveats, honoured: it plays only on FIRST RENDER — below-fold groups have
      finished before they are scrolled to, which we accept in exchange for a
      page that can never be stranded invisible — and it never touches the H1.
      The stagger is transition-delay: calc(var(--i) * 60ms); --i arrives as an
      inline style in new markup and from the nth-child ladder below in the
      existing markup, so no-JS pages stagger identically.

      Opacity ONLY, no travel: the chosen direction's strongest moment is the
      hero line drawing itself while everything else stands completely still.
      Blocks that carry figures do not slide; they are simply there a beat
      later. (The old gate translated 16px.)

      transform and border-color stay in the transition list ON PURPOSE:
      .tool — a .stag child on three pages — transitions both on hover, and a
      later `transition: opacity` here would silently beat that rule in the
      cascade and reduce the hover lift to a snap. The entrance itself still
      only ever changes opacity. ── */
@media (prefers-reduced-motion: no-preference) {
  .in,
  .stag > * {
    transition:
      opacity 500ms linear calc(var(--i, 0) * 60ms),
      transform 200ms var(--ease),
      border-color 200ms var(--ease);
  }
  @starting-style {
    .in,
    .stag > * {
      opacity: 0;
    }
  }
}
/* The no-JS stagger ladder. motion.js writes the same indices inline, so the
   two sources can never disagree. Eight is the widest group on any page. */
.stag > :nth-child(2) { --i: 1; }
.stag > :nth-child(3) { --i: 2; }
.stag > :nth-child(4) { --i: 3; }
.stag > :nth-child(5) { --i: 4; }
.stag > :nth-child(6) { --i: 5; }
.stag > :nth-child(7) { --i: 6; }
.stag > :nth-child(8) { --i: 7; }

/* ── "Perimeter stroke draw-on" (S), repurposed from buttons to the two
      OUTLINED provenance states. The computed and refused tiles are defined by
      an edge that refuses to fill, so that edge draws itself into being. A
      border cannot be drawn, so an inset SVG rect with pathLength="1" carries
      it and the CSS border goes transparent while the stroke runs.

      The catalogue's caveat is that the rect does not reflow with its box —
      here it is `position:absolute; inset:0` with `vector-effect` and
      preserveAspectRatio="none", so it tracks the tile at any size with no
      ResizeObserver. And per the same entry, this never doubles as the focus
      indicator: :focus-visible keeps the real outline. ── */
.fig {
  position: relative;
}
.edge {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
}
/* BASE IS THE END STATE: offset 0 is the fully drawn edge. The hidden state
   (offset 1) lives only in @starting-style below, so a browser under reduced
   motion, ?still=1 or with the transition unsupported renders the finished
   edge — the old shape kept the hidden state as the base and needed a class
   to arrive before the border existed at all. */
.edge rect {
  fill: none;
  stroke-width: 2;
  vector-effect: non-scaling-stroke;
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
}
.fig--computed .edge rect {
  stroke: var(--ink);
}
/* The dashed refusal keeps its dash rhythm; only the offset runs. */
.fig--refused .edge rect {
  stroke: var(--mark);
  stroke-dasharray: 0.014 0.008;
}
/* The CSS border steps aside only when the stroke that replaces it is
   actually in the DOM. The old gate was `.js .fig--computed` — but `.js`
   means "motion.js started", not "the edge exists", and a motion.js that
   stops injecting edges (or dies between add-class and inject) left these
   tiles with NO edge at all: the refusal's one defining mark, gone. :has() is
   the exact predicate. Browsers without :has() keep the CSS border under the
   drawn stroke — a slightly doubled edge, visible and honest. */
.fig--computed:has(.edge),
.fig--refused:has(.edge) {
  border-color: transparent;
}
/* The draw itself: @starting-style fires at the element's FIRST style pass,
   and motion.js inserts the .edge svg after load — so insertion is the
   trigger, no IntersectionObserver, no .in class. Non-numeric shape; allowed
   to move. */
@media (prefers-reduced-motion: no-preference) {
  .edge rect {
    transition: stroke-dashoffset 1100ms var(--ease) 200ms;
  }
  @starting-style {
    .edge rect {
      stroke-dashoffset: 1;
    }
  }
}

/* ── "Card hover lift with shadow crossfade" (S). Two pre-baked shadows on
      stacked pseudo-elements, crossfaded by opacity. box-shadow is NEVER
      transitioned — that repaints the blur every frame. Mirrored on
      :focus-visible per the caveat. ── */
.tool {
  isolation: isolate;
}
.tool::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 18px 44px -18px rgba(20, 22, 27, 0.34);
  opacity: 0;
  transition: opacity 220ms var(--ease);
  pointer-events: none;
  z-index: -1;
}
.tool:hover::after,
.tool:focus-within::after {
  opacity: 1;
}

/* ── "Arrow launch-and-return" (S). The arrow leaves through a clipping edge
      while a duplicate enters from the opposite corner, so it loops through the
      button rather than nudging. Masked to stop the 1px leak the entry warns
      about. ── */
.btn .arw {
  display: inline-block;
  overflow: hidden;
  width: 1em;
  height: 1em;
  position: relative;
  vertical-align: -0.05em;
}
.btn .arw i {
  position: absolute;
  inset: 0;
  font-style: normal;
  transition: transform 420ms var(--ease);
}
.btn .arw i + i {
  transform: translate(-120%, 120%);
}
.tool:hover .btn .arw i,
.btn:hover .arw i {
  transform: translate(120%, -120%);
}
.tool:hover .btn .arw i + i,
.btn:hover .arw i + i {
  transform: none;
}

/* ── "Press squash-and-spring" (S), trimmed to a press scale. ── */
.btn:active {
  transform: scale(0.975);
  transition-duration: 90ms;
}

/* ── "Underline wipe, in left out right" (S) on the nav. ── */
.hdr-nav a {
  position: relative;
}
.hdr-nav a::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -4px;
  height: 1.5px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 260ms var(--ease);
}
.hdr-nav a:hover::after {
  transform: scaleX(1);
}
.hdr-nav a:not(:hover)::after {
  transform-origin: right;
}

/* ── "Scroll-timeline header condense" (S). Pure CSS, off the main thread, and
      it unwinds exactly when you drag back up. Per the caveat, backdrop-filter
      is NOT in the keyframes — animating a blur radius is the expensive part —
      and the un-scrolled look is the default so unsupported browsers are fine. ── */
@supports (animation-timeline: scroll()) {
  @keyframes condense {
    to {
      padding-top: 12px;
      padding-bottom: 12px;
    }
  }
  @keyframes markshrink {
    to {
      scale: 0.86;
    }
  }
  .hdr {
    animation: condense linear both;
    animation-timeline: scroll(root block);
    animation-range: 0 160px;
  }
  .hdr-mark {
    animation: markshrink linear both;
    animation-timeline: scroll(root block);
    animation-range: 0 160px;
  }
}

/* ── "CSS-only reading progress bar" (S). Zero JS, scroll-driven. ── */
.progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 2px;
  width: 100%;
  background: var(--live);
  transform-origin: left;
  transform: scaleX(0);
  z-index: 40;
}
@supports (animation-timeline: scroll()) {
  @keyframes grow {
    to {
      transform: scaleX(1);
    }
  }
  .progress {
    animation: grow linear both;
    animation-timeline: scroll(root block);
  }
}

/* ── "Bento focus dim" (S). Hovering one tool recedes the others, so the grid
      answers the pointer as one object instead of three. ── */
@media (hover: hover) and (pointer: fine) {
  .tools:has(.tool:hover) .tool:not(:hover) {
    opacity: 0.55;
    transition: opacity 260ms var(--ease);
  }
}

/* ── "Magnetic button with lagging label" (M). Gated on a fine pointer per the
      caveat; motion.js caches the rect and zeroes travel under reduced motion. ── */
@media (hover: hover) and (pointer: fine) {
  .mag {
    transition: transform 420ms var(--ease);
  }
  .mag.pull {
    transition: transform 90ms linear;
  }
}

/* ── "Element-anchored" murmuration. The brand mark IS a murmuration, so the
      hero carries a small one: drifting points that link when they come close.
      The catalogue flags the constellation field as heavily overused as a stock
      tech background AND as a contrast hazard — so this one is deliberately
      NOT a background: it is a contained 300×200 element in the hero's dead
      right-hand space, sits behind no body copy at any breakpoint, and is
      removed entirely below 900px. IntersectionObserver gates the rAF. ── */
.murmur {
  position: absolute;
  right: 0;
  top: 50%;
  transform: translateY(-42%);
  width: 300px;
  height: 200px;
  pointer-events: none;
  opacity: 0.5;
}
.hero {
  position: relative;
}
@media (max-width: 900px) {
  .murmur {
    display: none;
  }
}

/* ── Reduced motion. Same treatment as before — durations clamped, nothing
      left invisible — but the per-gate rescue rules are mostly gone because
      the gates are gone: every hidden state now lives inside a
      (prefers-reduced-motion: no-preference) block, so under `reduce` it
      never exists and every base value IS the end state. The old block also
      reset .fig--computed's border to var(--mark), which was the WRONG token
      (that tile's border is --ink) — a bug that dissolves along with the gate
      it was patching. The two stroke lines below are belt-and-braces against
      a future rule regressing a base value. ── */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
  }
  .edge rect,
  .chart .curve {
    stroke-dashoffset: 0 !important;
  }
  .murmur {
    display: none;
  }
}

/* ── FAQ. Natural-language questions in <details> so the answer is in the DOM
      for extraction whether or not it is open — content gated behind an
      interaction is content an AI cannot read. ── */
.faq-q {
  border-bottom: 1px solid var(--rule);
  padding: 16px 0;
}
.faq-q summary {
  cursor: pointer;
  font-weight: 600;
  font-size: 16.5px;
  letter-spacing: -0.01em;
  list-style: none;
  display: flex;
  gap: 12px;
  align-items: baseline;
}
.faq-q summary::-webkit-details-marker { display: none; }
.faq-q summary::after {
  content: '+';
  margin-left: auto;
  font: 400 20px/1 var(--mono);
  color: var(--ink-2);
  transition: transform 240ms var(--ease);
}
.faq-q[open] summary::after { transform: rotate(45deg); }
.faq-q p {
  margin: 10px 0 2px;
  color: var(--ink-2);
  font-size: 15.5px;
  max-width: 68ch;
}
.note-updated {
  margin-top: 26px;
  font: 400 13px var(--mono);
  color: var(--ink-2);
}


/* ── Links inside prose are underlined, not merely coloured.
      axe flagged `link-in-text-block` on nine pages: a link distinguishable
      from its surrounding text by hue alone. That is precisely the failure
      this site's own thesis calls out — "no colour carries any of that" — so
      leaving it would have the homepage arguing against the rest of the site.
      Navigation, buttons and cards are excluded: they are not in a text block,
      and each already carries its own non-colour affordance. ── */
.prose a:not(.btn),
.fig-note a,
.tool p a,
.sub a,
.faq-q p a,
.ftr-legal a,
.note-updated a {
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-thickness: 1px;
}

/* ── Tap targets. axe/Lighthouse want 24px minimum; footer link rows and
      inline chrome links were under it. Padding rather than font-size so the
      type scale is untouched. ── */
.ftr-col a {
  padding: 6px 0;
  display: block;
  min-height: 24px;
}
.hdr-nav a {
  padding: 4px 0;
  display: inline-block;
  min-height: 24px;
}

/* ── Skip link. First focusable thing on the page, invisible until focused. ── */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 12px 18px;
  background: var(--ink);
  color: var(--paper);
  border-radius: 0 0 var(--r-tile) 0;
  font-weight: 600;
}
.skip-link:focus {
  left: 0;
  text-decoration: none;
}

/* ── The install band. A published free extension whose landing page had no
      way to install it is not a marketing problem, it is a broken funnel:
      /murmstack's only outbound link was to itself, and MurmStack and MurmAds
      had zero store links across all fifteen pages. Every page that argues for
      a product now ends with the way to get it. ── */
.install {
  display: flex;
  flex-wrap: wrap;
  gap: 18px 24px;
  align-items: center;
  justify-content: space-between;
  margin: var(--gutter) 0 8px;
  padding: 26px 28px;
  border: 1px solid var(--rule);
  border-radius: var(--r-block);
  background: var(--card);
}
.install b {
  display: block;
  font: 700 21px/1.1 var(--display);
  letter-spacing: -0.02em;
  margin-bottom: 5px;
}
/* `> div` is doing real work. Written as `.install span` this also caught the
   <span class="arw"> inside the button, which specificity-wise beat .btn's own
   colour and repainted the travelling arrow --ink-2 on the --live ground:
   1.52:1 in light and 1.11:1 in dark, on every page carrying an install band.
   Scoping to the band's text column leaves the arrow inheriting the button's
   --paper, which is the pairing --live was chosen against. It does NOT change
   the glyph's size — .btn already sets 15px, so the two rules agreed. */
.install > div span {
  color: var(--ink-2);
  font-size: 15px;
}

/* ── The Consent Gap entity pages ─────────────────────────────────────────
   119 generated pages (one per company, platform, consent tool and TLD) plus
   four hubs, all emitted by murmstack/scripts/seo-render.mjs. They reuse the
   site's tokens rather than introducing any: a page that arrives from a search
   result is most readers' FIRST sight of MurmTools, so it cannot look like it
   came from somewhere else. */
.seo {
  max-width: 68ch;
  margin: 0 auto;
  padding: 0 var(--gutter) 72px;
}
.seo-crumb {
  padding: 28px 0 20px;
  font-family: var(--mono);
  font-size: 12px;
  color: var(--ink-2);
}
.seo-crumb a { color: var(--ink-2); }
.seo h1 {
  font-family: var(--display);
  font-size: clamp(30px, 5vw, 42px);
  line-height: 1.1;
  letter-spacing: -0.02em;
  margin: 0 0 14px;
}
.seo-lede {
  font-size: 19px;
  line-height: 1.55;
  color: var(--ink-2);
  margin: 0 0 26px;
}
.seo h2 {
  font-size: 21px;
  line-height: 1.25;
  margin: 38px 0 10px;
}
.seo h3 { font-size: 16px; margin: 22px 0 6px; }
.seo p { line-height: 1.65; margin: 0 0 14px; }
/* Generated headings carry entity names with no break opportunity a browser
   will take on its own (`googletagmanager-com`, `platform-twitter-com`), which
   overflowed 320px viewports the same way the <code> hostnames did above. The
   rule lived INLINE in each of the 123 generated pages' <style> blocks (task
   #79); it belongs here with the rest of .seo, and the generator drops its
   inline copy in the same deploy. Scoped to .seo because `anywhere` on the
   sitewide h1/h2/h3 would happily break "MurmStack" in half on tight cards. */
.seo h1,
.seo h2,
.seo h3 {
  overflow-wrap: anywhere;
}

/* The method caveat. One component, rendered on every page — never authored,
   so it cannot drift into six paraphrases the way it did when writers carried
   it themselves. */
.seo-note {
  margin: 26px 0;
  padding: 14px 16px;
  background: var(--card);
  border-radius: var(--r-block);
  font-size: 14px;
  line-height: 1.55;
  color: var(--ink-2);
}
.seo-note p { margin: 0; }
.seo-note-sm {
  margin: 8px 0 0;
  font-size: 13px;
  color: var(--ink-2);
}

.seo-tbl {
  width: 100%;
  border-collapse: collapse;
  margin: 22px 0 6px;
  font-size: 14px;
}
.seo-tbl caption {
  text-align: left;
  padding-bottom: 8px;
  font-size: 13px;
  color: var(--ink-2);
}
.seo-tbl th,
.seo-tbl td {
  text-align: left;
  padding: 8px 10px 8px 0;
  border-bottom: 1px solid var(--rule);
}
.seo-tbl th { font-weight: 600; font-size: 13px; color: var(--ink-2); }
/* Row headers are the site name in the first column, not a column label. The
   generator is adding th scope="row" so a screen reader stops announcing
   "Before consent, 4" with nothing attached; without this rule those cells
   would inherit the <thead> treatment and every row would come out bold and
   13px in a 14px table. Deliberately NOT `color: var(--ink)`: th also carries
   a --card background further up, and an inked first column on a filled panel
   would read brighter than the data it labels. Matches nothing until the
   generator ships, so it is safe here now. */
.seo-tbl tbody th {
  font-weight: 400;
  font-size: 14px;
  color: var(--ink-2);
  background: none;
}
/* Counted figures take the numeral face and align right, the same rule the
   Scoreboard uses everywhere else a measured number appears. */
.seo-tbl td.num,
.seo-tbl th:not(:first-child) { text-align: right; }
.seo-tbl td.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }

.seo-faq h3 { font-size: 16px; }
.seo-cta {
  margin-top: 34px;
  padding-top: 18px;
  border-top: 1px solid var(--rule);
  font-size: 15px;
}

/* Hub listings, and the block on /consent-gap that points at them. */
.seo-list,
.idx-hubs { list-style: none; padding: 0; margin: 22px 0 0; }
.seo-list li {
  padding: 14px 0;
  border-bottom: 1px solid var(--rule);
}
.seo-list a { display: block; font-weight: 600; margin-bottom: 3px; }
.seo-list span {
  display: block;
  font-size: 14px;
  line-height: 1.5;
  color: var(--ink-2);
}
.idx-hubs li { padding: 7px 0; color: var(--ink-2); }

/* ═══════════════════════════════════════════════════════════════════════════
   HET MEETBLAD — the hero instrument (frame A)
   One real chart is the hero motif and everything around it stays quiet.
   Every point the markup draws comes from consent-gap-2026.json; this layer
   only knows how to dress it. Two layers, strictly separated:
     Sfeerlaag — ONE radial tint behind the block, ≤3% alpha, background only.
     UI-laag   — every line of text and every mark, drawn in tokens at
                 computed contrast (documented below).
   ═══════════════════════════════════════════════════════════════════════════ */

/* The sheet. The tint is 3% of --live, blended against --paper it lands on
   #f9faff (light) / #12151d (dark). MEASURED against those blended grounds:
     --ink    17.36:1 light   16.57:1 dark   (body/heads, floor 4.5)
     --ink-2   7.51:1 light    7.74:1 dark   (labels/notes, floor 4.5)
     --mark    3.19:1 light    3.06:1 dark   (ticks/guides, floor 3.0)
   Nothing the tint touches drops under its floor, so the sfeerlaag colours no
   text — it only warms the paper behind it. If the alpha is ever raised,
   re-run scratchpad contrast math before shipping: at 3% the --mark margin is
   already only 0.06 in dark. */
.sheet {
  position: relative;
  margin: clamp(26px, 4vw, 44px) 0 0;
  border: 1px solid var(--rule);
  border-radius: var(--r-block);
  background:
    radial-gradient(
      90% 120% at 70% -10%,
      color-mix(in srgb, var(--live) 3%, transparent),
      transparent 70%
    ),
    var(--paper);
  padding: clamp(18px, 3vw, 30px) clamp(14px, 2.6vw, 28px)
    clamp(14px, 2vw, 22px);
}
.sheet-head {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 18px;
  align-items: baseline;
  margin: 0 0 14px;
}
.sheet-head h2 {
  font: 600 clamp(16px, 1.8vw, 18px) / 1.35 var(--sans);
  margin: 0;
  letter-spacing: -0.01em;
  max-width: 46ch;
}
.sheet-head .src {
  margin-left: auto;
  font-size: 12.5px;
}
/* Source lines: mono, lowercase, machine furniture. Base class so the shelf
   can reuse it under each instrument. */
.src {
  font: 400 11.5px/1.5 var(--mono);
  color: var(--ink-2);
}
/* Below ~700px the sheet scrolls sideways INSIDE its own container rather
   than shrinking axis labels below legibility. The page body never scrolls
   sideways — that is the site-wide rule, and min-width on the svg plus
   overflow-x here is what keeps both true at once. */
.chart-scroll {
  overflow-x: auto;
}
.chart {
  display: block;
  width: 100%;
  height: auto;
  min-width: 700px;
}
/* Axis labels: DM Mono, lowercase by convention of the markup (never
   text-transform — a transformed "ms" would read as "MS", a unit change). */
.chart text {
  font: 400 12px var(--mono);
  fill: var(--ink-2);
}
.chart .axis-name {
  font: 400 12px var(--mono);
  fill: var(--ink-2);
}
/* The rug of gridlines is --rule (chrome, may be faint); the tick spine and
   the marked window are --mark (structural meaning: 3.19:1/3.06:1 on the
   tinted ground, over the 3:1 floor — see the .sheet comment). An SVG <g>
   with class "grid" also matches the page's .grid layout rule above; SVG
   containers ignore CSS layout, so that collision is inert, but the line
   styles are scoped under .chart to keep it that way. */
.chart .grid {
  stroke: var(--rule);
  stroke-width: 1;
}
.chart .tick {
  stroke: var(--mark);
  stroke-width: 1;
}
.chart .win {
  stroke: var(--mark);
  stroke-width: 1;
  stroke-dasharray: 2 3;
}
.chart .guide {
  stroke: var(--mark);
  stroke-width: 1;
  stroke-dasharray: 2 3;
}
.chart .area {
  fill: color-mix(in srgb, var(--ink) 4%, transparent);
}
.chart .curve {
  stroke: var(--ink);
  stroke-width: 2.25;
  fill: none;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.chart .dot {
  fill: var(--ink);
}
/* Annotations: the figures are finished values in the counted face. They may
   FADE IN as whole blocks (opacity only) — they never tick, roll or draw. */
.chart .anno-n {
  font: 700 20px var(--display);
  fill: var(--ink);
  letter-spacing: -0.02em;
}
.chart .anno-big {
  font: 700 30px var(--display);
  fill: var(--ink);
  letter-spacing: -0.03em;
}
.chart .anno-l {
  font: 400 11.5px var(--mono);
  fill: var(--ink-2);
}
.sheet-cap {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 22px;
  margin: 12px 2px 0;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--ink-2);
}
.sheet-cap b {
  color: var(--ink);
  font-weight: 600;
}
.sheet-cap a {
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-thickness: 1px;
}

/* ── "Normalised stroke draw-on" (S), pathLength="1" on the curve. The brief
      allowed animation-timeline: view() where supported — REJECTED here, on
      purpose: this block sits in the hero, so at scroll position 0 a view()
      timeline is already partway through its range. The draw would start
      half-finished and scrub BACKWARDS on the first scroll, and the timed
      annotation fades below would desynchronise from the tip of the line.
      view() is the right tool for elements that ENTER the viewport; a hero
      never does. So: on-load transition via @starting-style — zero JS, plays
      once, and the annotations are timed to where the tip actually is.
      The LINE moves; every figure in the block stands still from first paint
      (they fade in as finished values, opacity only). ── */
@media (prefers-reduced-motion: no-preference) {
  .chart .curve {
    stroke-dasharray: 1;
    stroke-dashoffset: 0;
    transition: stroke-dashoffset 1500ms var(--ease) 250ms;
  }
  .chart .anno {
    transition: opacity 400ms linear;
  }
  .chart .anno-1 { transition-delay: 420ms; }  /* tip passes 46ms almost at once */
  .chart .anno-2 { transition-delay: 900ms; }  /* tip crosses the median ~1/3 in */
  .chart .anno-3 { transition-delay: 1750ms; } /* endpoint lands as the draw ends */
  @starting-style {
    .chart .curve {
      stroke-dashoffset: 1;
    }
    .chart .anno {
      opacity: 0;
    }
  }
}

/* ── The provenance chip. Mono, lowercase content by convention, and the four
      provenance SHAPES available as a leading swatch — the same fill /
      outline / dash / hatch vocabulary at 9px, so a chip can say "counted ·
      consent-gap-2026.json" with the mark doing the claiming. Colour carries
      none of it, exactly as on the tiles. ── */
.prov {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font: 400 12px/1 var(--mono);
  color: var(--ink-2);
  border: 1px solid var(--rule);
  border-radius: var(--r-chip);
  padding: 5px 11px;
}
.prov::before {
  content: '';
  width: 9px;
  height: 9px;
  border-radius: 2px;
  flex: none;
}
.prov--counted::before {
  background: var(--ink);
}
.prov--computed::before {
  border: 1px solid var(--ink);
}
.prov--refused::before {
  border: 1px dashed var(--mark);
}
/* The 9px hatch runs at 1px/3.5px instead of the tile's 1.5px/6px — same
   token (--mark, 3.33:1 light / 3.17:1 dark), finer period, because two
   stripes of the tile pattern do not fit a chip swatch. */
.prov--withheld::before {
  border: 1px solid var(--rule);
  background-image: repeating-linear-gradient(
    45deg,
    var(--mark) 0 1px,
    transparent 1px 3.5px
  );
}

/* ═══════════════════════════════════════════════════════════════════════════
   DE PLANK — the four real popup captures as objects in one vitrine (frame C)
   Sfeerlaag: the plinth gradient and ONE large low studio shadow. UI-laag:
   every label sits below the plinth on --paper at the ratios already proven
   above (--ink 18.10:1/17.15:1, --ink-2 7.83:1/8.01:1). No glassmorphism, no
   blur, no text over the gradient.
   ═══════════════════════════════════════════════════════════════════════════ */
.shelf {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 18px;
}
@media (max-width: 1023px) {
  .shelf {
    grid-template-columns: repeat(2, 1fr);
  }
}
@media (max-width: 600px) {
  .shelf {
    grid-template-columns: 1fr;
  }
}
/* The plank itself: one continuous hairline running behind all four plinths
   at their floor (plinth height 300px, line at 299px), visible in the gaps —
   the shelf the instruments stand on. Desktop only: on stacked layouts there
   is no single shelf to draw, and a line through the middle of a 2×2 grid
   reads as a strikethrough. */
@media (min-width: 1024px) {
  .shelf::before {
    content: '';
    position: absolute;
    top: 299px;
    left: calc(-1 * var(--gutter));
    right: calc(-1 * var(--gutter));
    height: 1px;
    background: var(--rule);
  }
}
.inst {
  display: flex;
  flex-direction: column;
}
/* The plinth: the lit box the object stands in. Fixed light in both schemes
   (see the --studio tokens). overflow:hidden crops the capture at the floor,
   which is what makes it read as standing IN the box, not pasted on it. */
.plinth {
  position: relative;
  height: 300px;
  overflow: hidden;
  border-radius: var(--r-block);
  background: linear-gradient(180deg, var(--studio-hi) 0%, var(--studio-lo) 100%);
  border: 1px solid var(--rule);
}
/* The object: a real popup capture. ONE big low shadow (--shadow-obj), no
   glass, no reflection — a studio product shot, not a mockup frame. */
.obj {
  width: 76%;
  margin: 34px auto 0;
  display: block;
  border-radius: 10px 10px 0 0;
  box-shadow: var(--shadow-obj);
}
.obj img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: inherit;
}
.inst-body {
  padding: 18px 4px 0;
  display: flex;
  flex-direction: column;
  flex: 1;
}
/* The identity plane: a 12px accent square beside the name. Identity, never
   meaning — the same claim the store tiles make. Falls back to --ink if a
   markup forgets its --accent. */
.inst h3 {
  font: 700 22px/1.1 var(--display);
  letter-spacing: -0.02em;
  margin: 0 0 4px;
  display: flex;
  align-items: center;
  gap: 9px;
}
.inst h3 i {
  width: 12px;
  height: 12px;
  border-radius: 4px;
  background: var(--accent, var(--ink));
  flex: none;
}
.inst .role {
  font: 400 12px/1.5 var(--mono);
  color: var(--ink-2);
  text-transform: lowercase;
}
.inst p {
  color: var(--ink-2);
  font-size: 14.5px;
  line-height: 1.55;
  margin: 10px 0 14px;
}
.inst ul {
  list-style: none;
  margin: 0 0 6px;
  padding: 0;
}
.inst li {
  font-size: 13.5px;
  padding: 6px 0;
  border-bottom: 1px solid var(--rule);
  display: flex;
  gap: 10px;
  align-items: baseline;
  color: var(--ink-2);
}
.inst li b {
  font: 700 15px/1 var(--display);
  color: var(--ink);
  margin-left: auto;
  flex: none;
}
.inst .src {
  margin: 8px 0 14px;
}
/* The engraving: what this instrument refuses to measure, drawn in the
   refused vocabulary — 1px dash, an em-dash in the markup, text in --ink-2
   (7.83:1 light / 8.01:1 dark). margin-top:auto pins all four engravings to
   the same baseline across the shelf regardless of copy length. */
.refuse {
  border: 1px dashed var(--mark);
  border-radius: var(--r-tile);
  padding: 12px 14px;
  margin-top: auto;
}
.refuse b {
  display: block;
  font: 700 15px/1.2 var(--display);
  letter-spacing: -0.01em;
  margin-bottom: 4px;
}
.refuse span {
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--ink-2);
  display: block;
}
.inst .btn {
  margin-top: 14px;
  align-self: flex-start;
}

/* ═══════════════════════════════════════════════════════════════════════════
   STILL-MODUS — html.still freezes the whole page at its END states.
   Set before first paint (two lines of inline script reading ?still=1), used
   for deterministic capture and for anyone who wants the sheet without the
   theatre. It works because every base value in this file IS the end state:
   killing animation and transition leaves the finished page. The four
   !important lines under the kill are belt-and-braces so a future rule that
   regresses a base value cannot silently break still mode.
   LAST IN THE FILE on purpose: still outranks everything above it. ── */
html.still {
  scroll-behavior: auto;
}
html.still *,
html.still *::before,
html.still *::after {
  animation: none !important;
  transition: none !important;
}
html.still .edge rect,
html.still .chart .curve {
  stroke-dashoffset: 0 !important;
}
html.still .in,
html.still .stag > *,
html.still .chart .anno,
html.still .ln > i {
  opacity: 1 !important;
  transform: none !important;
  filter: none !important;
}
/* The murmuration is rAF-driven — CSS cannot pause a canvas, so still mode
   removes it. motion.js also skips it when it sees .still. */
html.still .murmur {
  display: none;
}
