/* ==========================================================================
   FM — Frontend Master Design System  v1
   One brand layer for every page: 3 palettes (brass default | evergreen | teal),
   each with light + dark. Palette is stamped as html[data-palette],
   appearance as html[data-theme] ("light" | "dark"; absent = follow OS).
   Persisted in localStorage: 'palette' + 'theme' (canonical site-wide keys).

   Load order on a migrated page:
     1. inline pre-paint bootstrap (stamps attributes — see fm.js header)
     2. Google Fonts (Bricolage Grotesque, Inter, Source Serif 4, JetBrains Mono)
     3. this file   4. page-specific <style>   5. fm.js (defer)

   Every color pair here is WCAG-AA-verified or better (audit 2026-08-13).
   ========================================================================== */

/* ---------- self-hosted webfonts ----------------------------------------
   Was: two render-blocking hops to fonts.googleapis.com + fonts.gstatic.com
   (and Google caps that CSS at ~1 day, so repeat visitors revalidated it on
   every visit). Now same-origin, discovered as this file parses, and served
   under the /assets/* immutable-for-a-year header.

   Variable files: one woff2 per family per subset carries the whole weight
   range, so `font-weight: <min> <max>` replaces Google's per-weight blocks.
   unicode-range gates the download — a page with no Greek never fetches the
   Greek file, so extra subsets are free at runtime. Keep `swap` to match the
   previous &display=swap behaviour: text paints immediately in the fallback.
   ---------------------------------------------------------------------- */
@font-face {
  font-family: 'Bricolage Grotesque';
  font-style: normal;
  font-weight: 600 800;
  font-display: swap;
  src: url('/assets/fonts/bricolage-grotesque-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Bricolage Grotesque';
  font-style: normal;
  font-weight: 600 800;
  font-display: swap;
  src: url('/assets/fonts/bricolage-grotesque-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('/assets/fonts/inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('/assets/fonts/inter-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('/assets/fonts/inter-greek.woff2') format('woff2');
  unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
@font-face {
  font-family: 'Source Serif 4';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/assets/fonts/source-serif-4-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Source Serif 4';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/assets/fonts/source-serif-4-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Source Serif 4';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/assets/fonts/source-serif-4-greek.woff2') format('woff2');
  unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/assets/fonts/jetbrains-mono-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/assets/fonts/jetbrains-mono-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/assets/fonts/jetbrains-mono-greek.woff2') format('woff2');
  unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}

/* ---------- metric-matched fallback for Inter (CLS) ----------
   font-display: swap paints the local fallback first and reflows the page when
   the webfont lands; Lighthouse attributed every page's residual CLS to "Web
   font". These two Arial faces are size-adjusted so an Inter line wraps onto
   the same number of lines as the webfont it stands in for. Ratios were
   MEASURED IN CHROME (2026-09-18) as the median width ratio of ~1,100 real
   text runs against these exact faces — Inter 400: 1.067 (p10–p90 1.054–
   1.075); Inter 600/700/800: 1.013/1.027/1.038, weighted to the common 600.
   Blocking the woff2 files and comparing every element's position with the
   webfont layout: home page 76px → 18px mean error at 390, 24 → 0 at 1440;
   CLS with fonts delayed 800 ms: home@390 0.137 → 0.001, the BSD question
   pages 0.156 → 0.000. Font tables are NOT enough: @capsizecss/unpack gives
   one ratio per family, but Chrome's synthetic bold keeps regular advances,
   so bold Inter on faux-bold Arial is ~10% too narrow — hence the second face.
   Only Inter: Bricolage headings need anywhere from 0.98 to 1.05 against
   Arial Bold (the home <h1> is at 0.98 and already lands within 0.4% on the
   system-ui fallback), Source Serif 4 already falls to a near-metric Iowan Old
   Style on macOS, and Android/Windows-only faces could not be measured here.
   ascent/descent are the webfont's table values divided by size-adjust.
   Re-measure if the Inter file changes. Page-level rules that name a family
   directly bypass this; only the --font-body token carries it. */
@font-face {
  font-family: 'Inter Fallback: Arial';
  font-weight: 100 500;
  src: local('Arial'), local('ArialMT');
  ascent-override: 90.537%;
  descent-override: 22.543%;
  line-gap-override: 0%;
  size-adjust: 107.0%;
}
@font-face {
  font-family: 'Inter Fallback: Arial';
  font-weight: 600 900;
  src: local('Arial Bold'), local('Arial-BoldMT');
  ascent-override: 94.975%;
  descent-override: 23.648%;
  line-gap-override: 0%;
  size-adjust: 102.0%;
}

/* ---------- static tokens (palette-independent) ---------- */
:root {
  --font-display: 'Bricolage Grotesque', 'Inter', system-ui, sans-serif;
  --font-body: 'Inter', 'Inter Fallback: Arial', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --font-serif: 'Source Serif 4', 'Iowan Old Style', 'Palatino Linotype', Georgia, serif;
  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  --font-base: 18px;
  --line-height: 1.7;
  /* Content column is a SHARE of the viewport, not a fixed px: 15% gutter each
     side, 70% for the text. Rahul, 2026-09-07: "let's leave 15% space on the
     left and 15% space on the right, and use all the available 70% space".
     Was 980px, was 820px before that. Narrow screens step up to a near-full
     bleed because 70% of a phone is not a column, it is a gutter with words
     in it. Line length is bought back by the larger prose size below. */
  /* CEILING, added after shipping the bare 70vw: --wide-width is a fixed 1200px,
     so above ~1714px the prose grew PAST the wide band and every .fig.wide,
     playground and comparison table ended up NARROWER than the paragraphs around
     it — the hierarchy inverted (measured 1290px prose vs 1200px band at 1920,
     1738 vs 1200 at 2560). The cap is a no-op below 1714px, so 70% still holds
     at the widths this was asked for and looked at. */
  --max-width: min(70vw, var(--wide-width));
  --wide-width: 1200px;   /* nav, footer, wide layouts */

  --radius-sm: 8px;
  --radius: 12px;
  --radius-lg: 20px;
  --radius-full: 9999px;

  --dur-fast: 150ms;
  --dur: 250ms;
  --dur-slow: 400ms;
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
  --transition: var(--dur) var(--ease);
}

/* ---------- INK & BRASS (default palette) — light ---------- */
:root {
  color-scheme: light;
  --bg: #faf7f0;
  --surface: #ffffff;
  --surface-2: #f1ede2;
  --text: #1f1c14;
  --text-muted: #6b6455;
  --border: rgba(31, 28, 20, 0.14);
  --border-strong: rgba(31, 28, 20, 0.26);
  --accent: #7a5f13;
  --accent-strong: #7a5f13;
  --on-accent: #ffffff;
  --accent-soft: #826719;
  --accent-2: #5c5443;
  --accent-glow: rgba(122, 95, 19, 0.22);
  --grad: linear-gradient(120deg, #7a5f13, #8a6d1a 55%, #5c5443);
  --code-bg: #1a1710;
  --code-text: #e8e4d8;
  --nav-bg: rgba(250, 247, 240, 0.85);
  --shadow-1: 0 1px 2px rgba(31, 28, 20, 0.05), 0 6px 20px rgba(31, 28, 20, 0.06);
  --shadow-2: 0 2px 4px rgba(31, 28, 20, 0.06), 0 14px 34px rgba(31, 28, 20, 0.1);
}

/* ---------- EVERGREEN — light ---------- */
:root[data-palette="evergreen"] {
  --bg: #faf9f5;
  --surface: #ffffff;
  --surface-2: #f1f0ea;
  --text: #1c2321;
  --text-muted: #5f6b66;
  --border: rgba(28, 35, 33, 0.14);
  --border-strong: rgba(28, 35, 33, 0.26);
  --accent: #166a4e;          /* links, interactive text — 6.2:1 on bg */
  --accent-strong: #166a4e;   /* primary button fill */
  --on-accent: #ffffff;       /* text on accent-strong — 6.6:1 */
  --accent-soft: #15684c;     /* AA at normal size on --surface-2 (was #1a7f5c, 3.40:1) */
  --accent-2: #7a6415;        /* brass: eyebrows, highlights — 5.0:1+ all surfaces */
  --accent-glow: rgba(22, 106, 78, 0.22);
  --grad: linear-gradient(120deg, #166a4e, #0f6b52 55%, #7a6415);
  --code-bg: #131a16;
  --code-text: #e3e8e4;
  --nav-bg: rgba(250, 249, 245, 0.85);
  --shadow-1: 0 1px 2px rgba(28, 35, 33, 0.05), 0 6px 20px rgba(28, 35, 33, 0.06);
  --shadow-2: 0 2px 4px rgba(28, 35, 33, 0.06), 0 14px 34px rgba(28, 35, 33, 0.1);
}

/* ---------- DEEP TEAL — light ---------- */
:root[data-palette="teal"] {
  --bg: #f7fafa;
  --surface: #ffffff;
  --surface-2: #ecf2f2;
  --text: #16211f;
  --text-muted: #59696a;
  --border: rgba(22, 33, 31, 0.14);
  --border-strong: rgba(22, 33, 31, 0.26);
  --accent: #0f766e;
  --accent-strong: #0f766e;
  --on-accent: #ffffff;
  --accent-soft: #0e7a6f;
  --accent-2: #48646a;
  --accent-glow: rgba(15, 118, 110, 0.22);
  --grad: linear-gradient(120deg, #0f766e, #0d5f59 55%, #48646a);
  --code-bg: #101c1b;
  --code-text: #dfe9e8;
  --nav-bg: rgba(247, 250, 250, 0.85);
  --shadow-1: 0 1px 2px rgba(22, 33, 31, 0.05), 0 6px 20px rgba(22, 33, 31, 0.06);
  --shadow-2: 0 2px 4px rgba(22, 33, 31, 0.06), 0 14px 34px rgba(22, 33, 31, 0.1);
}

/* ---------- dark values (shared macro-block, applied two ways) ----------
   1. OS dark + user hasn't forced light   2. user forced dark            */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg: #15130d;
    --surface: #1c1913;
    --surface-2: #242017;
    --text: #ece9e0;
    --text-muted: #a49e8e;
    --border: rgba(236, 233, 224, 0.12);
    --border-strong: rgba(236, 233, 224, 0.24);
    --accent: #e8c547;
    --accent-strong: #e8c547;
    --on-accent: #15130d;
    --accent-soft: #e8c547;
    --accent-2: #cfc6a8;
    --accent-glow: rgba(232, 197, 71, 0.18);
    --grad: linear-gradient(120deg, #e8c547, #f0d878 55%, #cfc6a8);
    --code-bg: #1a1710;
    --code-text: #e8e4d8;
    --nav-bg: rgba(21, 19, 13, 0.82);
    --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.32);
    --shadow-2: 0 2px 4px rgba(0, 0, 0, 0.34), 0 14px 34px rgba(0, 0, 0, 0.42);
  }
  :root:not([data-theme="light"])[data-palette="evergreen"] {
    --bg: #101412;
    --surface: #181d1a;
    --surface-2: #202622;
    --text: #e8eae6;
    --text-muted: #9aa69f;
    --border: rgba(232, 234, 230, 0.12);
    --border-strong: rgba(232, 234, 230, 0.24);
    --accent: #4ade9d;
    --accent-strong: #5fe3ad;
    --on-accent: #101412;
    --accent-soft: #4ade9d;
    --accent-2: #e8c547;
    --accent-glow: rgba(95, 227, 173, 0.18);
    --grad: linear-gradient(120deg, #4ade9d, #5fe3ad 55%, #e8c547);
    --code-bg: #161c18;
    --code-text: #e3e8e4;
    --nav-bg: rgba(16, 20, 18, 0.82);
  }
  :root:not([data-theme="light"])[data-palette="teal"] {
    --bg: #0d1214;
    --surface: #141b1e;
    --surface-2: #1c2528;
    --text: #e6ebec;
    --text-muted: #93a3a6;
    --border: rgba(230, 235, 236, 0.12);
    --border-strong: rgba(230, 235, 236, 0.24);
    --accent: #2dd4bf;
    --accent-strong: #2dd4bf;
    --on-accent: #0d1214;
    --accent-soft: #2dd4bf;
    --accent-2: #a3c4c0;
    --accent-glow: rgba(45, 212, 191, 0.18);
    --grad: linear-gradient(120deg, #2dd4bf, #5eead4 55%, #a3c4c0);
    --code-bg: #10191b;
    --code-text: #dfe9e8;
    --nav-bg: rgba(13, 18, 20, 0.82);
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #15130d;
  --surface: #1c1913;
  --surface-2: #242017;
  --text: #ece9e0;
  --text-muted: #a49e8e;
  --border: rgba(236, 233, 224, 0.12);
  --border-strong: rgba(236, 233, 224, 0.24);
  --accent: #e8c547;
  --accent-strong: #e8c547;
  --on-accent: #15130d;
  --accent-soft: #e8c547;
  --accent-2: #cfc6a8;
  --accent-glow: rgba(232, 197, 71, 0.18);
  --grad: linear-gradient(120deg, #e8c547, #f0d878 55%, #cfc6a8);
  --code-bg: #1a1710;
  --code-text: #e8e4d8;
  --nav-bg: rgba(21, 19, 13, 0.82);
  --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.32);
  --shadow-2: 0 2px 4px rgba(0, 0, 0, 0.34), 0 14px 34px rgba(0, 0, 0, 0.42);
}
:root[data-theme="dark"][data-palette="evergreen"] {
  --bg: #101412;
  --surface: #181d1a;
  --surface-2: #202622;
  --text: #e8eae6;
  --text-muted: #9aa69f;
  --border: rgba(232, 234, 230, 0.12);
  --border-strong: rgba(232, 234, 230, 0.24);
  --accent: #4ade9d;
  --accent-strong: #5fe3ad;
  --on-accent: #101412;
  --accent-soft: #4ade9d;
  --accent-2: #e8c547;
  --accent-glow: rgba(95, 227, 173, 0.18);
  --grad: linear-gradient(120deg, #4ade9d, #5fe3ad 55%, #e8c547);
  --code-bg: #161c18;
  --code-text: #e3e8e4;
  --nav-bg: rgba(16, 20, 18, 0.82);
}
:root[data-theme="dark"][data-palette="teal"] {
  --bg: #0d1214;
  --surface: #141b1e;
  --surface-2: #1c2528;
  --text: #e6ebec;
  --text-muted: #93a3a6;
  --border: rgba(230, 235, 236, 0.12);
  --border-strong: rgba(230, 235, 236, 0.24);
  --accent: #2dd4bf;
  --accent-strong: #2dd4bf;
  --on-accent: #0d1214;
  --accent-soft: #2dd4bf;
  --accent-2: #a3c4c0;
  --accent-glow: rgba(45, 212, 191, 0.18);
  --grad: linear-gradient(120deg, #2dd4bf, #5eead4 55%, #a3c4c0);
  --code-bg: #10191b;
  --code-text: #dfe9e8;
  --nav-bg: rgba(13, 18, 20, 0.82);
}

/* ---------- base ---------- */
html {
  font-size: var(--font-base);
  scroll-behavior: smooth;
}
/* Clears the sticky .top-nav on in-page jumps; 84px matches NAV_OFFSET in fm-roadmap.js.
   A zero-specificity margin rather than html scroll-padding, which would ADD to the
   scroll-margin-top many pages already set. Form controls and listbox options excluded:
   scrollIntoView inside inner panels must not gain the offset. [id] stays INSIDE :where()
   or the rule outranks a page's own h2 { scroll-margin-top }. */
:where(:is(main, section, article, h1, h2, h3, h4, h5, h6, li, div)[id]:not([role="option"])) {
  scroll-margin-top: 84px;
}
body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: var(--line-height);
  -webkit-font-smoothing: antialiased;
  transition: background-color var(--transition), color var(--transition);
}
/* `text-wrap: pretty`, never `balance`. Balance splits a heading into two
   roughly EQUAL lines rather than filling the first one, so a heading with
   room for nine more characters still broke in half and left the right side of
   the column empty. `pretty` fills each line and only steps in to stop a
   one-word last line. */
h1, h2, h3, h4 {
  font-family: var(--font-display);
  line-height: 1.15;
  text-wrap: pretty;
  color: var(--text);
}
::selection { background: var(--accent-glow); }

a { color: var(--accent); }
a:focus-visible,
button:focus-visible,
[tabindex]:focus-visible {
  outline: 3px solid var(--accent-glow);
  outline-offset: 2px;
  border-radius: 4px;
}
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--accent-strong);
  color: var(--on-accent);
  padding: 0.5rem 1rem;
  border-radius: var(--radius-sm);
  text-decoration: none;
  z-index: 1000;
}
.skip-link:focus { left: 1rem; top: 1rem; }

/* Visually-hidden text. 111 pages carry class="sr-only" — labels for icon-only
   controls, "Search results" on a live region, unit words in stat rows — but it
   was only ever DEFINED in a handful of page-level <style> blocks, so on 11 of
   them the hidden text was rendering in full view. It belongs here, with the
   rest of the shared vocabulary, so a page that uses the class never has to
   remember to ship the rule.
   clip-path is the modern half; `clip` stays for older engines. Do not use
   display:none or visibility:hidden — both remove the text from the
   accessibility tree, which is the opposite of what this class is for. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
/* A skip-link-style control that hides until focused opts in with .sr-only-focusable. */
.sr-only-focusable:focus, .sr-only-focusable:focus-visible {
  position: static;
  width: auto;
  height: auto;
  margin: 0;
  overflow: visible;
  clip: auto;
  clip-path: none;
  white-space: normal;
}

code, pre { font-family: var(--font-mono); }
/* JetBrains Mono draws === and => as single glyphs. Readers copying code, and
   beginners reading it, need the characters actually typed. :where() keeps
   this at zero specificity so a page rule can still opt back in. */
:where(code, pre, kbd, samp) { font-variant-ligatures: none; }

/* Inline code chips hold unbreakable tokens (long identifiers, URLs, flags).
   Without this they set a min-content floor on their block, which no amount of
   grid/flex stacking can shrink — measured as horizontal page overflow on 6
   posts at 390px. Only affects text that would otherwise overflow. Block-level
   code inside <pre> is excluded: <pre> already scrolls on its own axis. */
:not(pre) > code { overflow-wrap: anywhere; }
:not(pre) > code {
  /* Inline code chips stay DARK in both themes, like code blocks — many
     pages color them with bright syntax hues (cyan/gold) that need a dark
     ground. Never swap this to a light surface. */
  background: var(--code-bg);
  color: var(--code-text);
  border-radius: 5px;
  padding: 0.1em 0.4em;
  font-size: max(0.93em, 15.5px);
}
/* Secondary text ON the dark code ground. Exists because the obvious thing —
   var(--text-muted) — is a LIGHT-mode token: on --code-bg it measured 3.04:1 in
   light mode across 26 elements, and var(--text) measured 1.05:1, i.e. invisible.
   Derived so it tracks whatever --code-text/--code-bg the active palette sets;
   measured 7.4:1 in all five palettes. Anything sitting on --code-bg uses
   --code-text or this, never --text/--text-muted. */
:root { --code-text-muted: color-mix(in srgb, var(--code-text) 70%, var(--code-bg)); }
/* Semantic accents ON the dark code ground. --fsd-emerald-text / --fsd-amber-text
   are darkened for the LIGHT page ground; on --code-bg they measure 3.34:1 and
   worse. These are the dark-theme halves of the same hues, which is what a dark
   ground wants in either theme, and they match the .fsd-code syntax palette. */
:root { --code-ok: #34d399; --code-warn: #fcd34d; --code-err: #fca5a5; --code-info: #93b4f5; }

pre {
  background: var(--code-bg);
  color: var(--code-text);
  border-radius: var(--radius);
  padding: 1.1rem 1.3rem;
  overflow-x: auto;
  /* 17px code floor at the 18px base — Rahul: 16px code still reads too small */
  font-size: 0.95rem;
  line-height: 1.6;
}

/* Article byline — author, publication date, and a revision date ONLY when the
   page has genuinely been revised. Every post carries author and dates in its
   Article JSON-LD, but 203 of 216 showed nothing to a human reader, which is
   the half Google's people-first guidance is actually about. One shared rule
   here rather than per-post markup, so the line reads identically everywhere. */
.fm-byline {
  /* Deliberately NOT a flex container. A flex box establishes its own layout
     and stops honouring the ancestor's text-align, so on the many posts whose
     hero is centred the byline alone sat hard left under a centred headline.
     Inline children inherit text-align, so this one rule is centred inside a
     centred hero and left-aligned inside left-aligned prose, with no per-page
     override anywhere. */
  margin: 0.9rem 0 1.4rem;
  font-family: var(--font-body);
  font-size: 0.9rem;
  line-height: 1.7;
  color: var(--text-muted);
  text-wrap: pretty;
}
.fm-byline a { color: var(--text); font-weight: 600; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 0.2em; text-decoration-color: color-mix(in oklab, currentColor 40%, transparent); }
.fm-byline a:hover { text-decoration-color: currentColor; }
.fm-byline .sep { opacity: 0.5; margin: 0 0.45rem; }
.fm-byline time { font-variant-numeric: tabular-nums; }
.fm-byline .fm-byline-rev { font-style: italic; }

/* long-form reading face (opt-in per content block) */
.fm-prose p, .fm-prose li {
  font-family: var(--font-serif);
  font-size: 1.06rem;
  line-height: 1.75;
}

/* hero gradient headline — the brand signature */
.fm-grad {
  background: var(--grad);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

/* ---------- cross-document view transitions ----------
   The UA default composites the old/new page snapshots with
   mix-blend-mode:plus-lighter, which only crossfades cleanly when the two
   opacity curves sum to exactly 1 at every instant. Custom old/new animations
   with different durations broke that invariant: summed luminance overshot
   (clipping to white) and then dipped to the bare canvas — a white flash on
   every navigation. So: normal blending, the outgoing page stays frozen at
   full opacity underneath, and the new page fades/slides in over it. The
   canvas is never visible through the stack. */
@view-transition { navigation: auto; }
.top-nav { view-transition-name: site-nav; }
@keyframes fm-vt-in {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}
::view-transition-image-pair(root) { isolation: auto; }
::view-transition-old(root) {
  animation: none;
  mix-blend-mode: normal;
  opacity: 1;
}
::view-transition-new(root) {
  animation: fm-vt-in var(--dur) var(--ease) both;
  mix-blend-mode: normal;
}
/* A navigation between a light ground and a dark one (the cream FM pages and
   the forced-dark /system-design/ hub) gets a longer, symmetric fade. The
   default curve puts ~80% of the change into the first 80 ms, exactly the
   frames a fresh navigation drops, and cream-to-black in one frame reads as
   a flash. fm.js adds the type at pagereveal when the ground luminance jumps
   (see "tone-shift view transition" there); the nav group follows so the bar
   does not change tone ahead of the page. Measured: largest single-frame
   luminance step 116-184 (of 255) before, 12-34 after, both directions. */
html:active-view-transition-type(fm-tone-shift)::view-transition-new(root),
html:active-view-transition-type(fm-tone-shift)::view-transition-group(site-nav),
html:active-view-transition-type(fm-tone-shift)::view-transition-old(site-nav),
html:active-view-transition-type(fm-tone-shift)::view-transition-new(site-nav) {
  animation-duration: 450ms;
  animation-timing-function: ease-in-out;
}

/* ---------- nav ---------- */
.top-nav {
  position: sticky;
  top: 0;
  z-index: 100;
  background: var(--nav-bg);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--border);
}
.nav-inner {
  max-width: var(--wide-width);
  box-sizing: border-box;
  margin: 0 auto;
  padding: 12px 32px;
  min-height: 40px;
  display: flex;
  align-items: center;
  gap: 14px;
}
.nav-logo {
  font-family: var(--font-display);
  font-size: 20px;
  line-height: 1.4;
  font-weight: 800;
  color: var(--accent);
  text-decoration: none;
  letter-spacing: -0.02em;
  white-space: nowrap;
  transition: transform var(--dur-fast) var(--ease);
}
.nav-logo:hover { transform: scale(1.04); }
.nav-links {
  display: flex;
  align-items: center;
  gap: 22px;
  margin-left: auto;
}
.nav-links a {
  color: var(--text-muted);
  text-decoration: none;
  font-size: 14px;
  line-height: 1.4;
  font-weight: 500;
  white-space: nowrap;
  transition: color var(--dur-fast) var(--ease);
}
.nav-links a:hover, .nav-links a.active { color: var(--accent); }
.nav-links a.nav-promo-jsking { color: var(--accent-2); font-weight: 700; }
.hamburger {
  display: none;
  background: none;
  border: none;
  color: var(--text);
  font-size: 24px;
  cursor: pointer;
  padding: 4px 8px;
}

/* ---------- theme & palette switcher (top-left, before the logo) ---------- */
.fm-switch { position: relative; }
/* The button's markup carries a "◐" (U+25D0) on 459 pages. Two things were wrong with relying on
   it, and they compound: a <button> does not inherit the page font, so this element computed to
   Arial rather than the FM stack; and U+25D0 is in Geometric Shapes, which none of the three
   subsets we self-host (latin, latin-ext, greek) covers. The glyph therefore came from whatever
   the platform happened to substitute — a different shape, weight and baseline on every OS, and on
   some it is a box. The icon is drawn in CSS instead, so it is identical everywhere and follows
   currentColor. The glyph stays in the markup as the no-CSS fallback; the button's accessible name
   comes from its aria-label, not from the character. */
.fm-switch-btn {
  /* The icon is CENTRED BY ABSOLUTE POSITIONING, not by the grid.
     The "◐" fallback character is still a real text node inside the button, and
     a text node in a grid container becomes an anonymous grid ITEM. Even at
     font-size:0 it claimed a second row, so the ::before circle and that empty
     row were centred together and the visible icon landed 4.25px low —
     measured 13.75px above / 5.25px below in a 34px button. Taking the circle
     out of flow makes the fallback glyph weightless. */
  position: relative;
  width: 34px;
  height: 34px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--accent);
  font: inherit;
  font-size: 0;
  line-height: 0;
  cursor: pointer;
  transition: transform var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.fm-switch-btn::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: linear-gradient(to right, currentColor 0 50%, transparent 50% 100%);
  box-shadow: inset 0 0 0 1.5px currentColor;
}
.fm-switch-btn:hover { transform: rotate(20deg); border-color: var(--accent-soft); }
.fm-switch-pop {
  position: absolute;
  top: calc(100% + 10px);
  left: 0;
  min-width: 218px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-2);
  padding: 14px;
  z-index: 200;
}
.fm-switch-pop[hidden] { display: none; }
.fm-switch-label {
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 8px;
}
.fm-switch-label + .fm-switch-label { margin-top: 14px; }
.fm-palettes { display: grid; gap: 6px; }
.fm-palette-opt {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  border: 1px solid var(--border);
  background: none;
  color: var(--text);
  border-radius: var(--radius-sm);
  padding: 8px 10px;
  font: 500 13px/1 var(--font-body);
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease), background-color var(--dur-fast) var(--ease);
}
.fm-palette-opt:hover { background: var(--surface-2); }
.fm-palette-opt[aria-pressed="true"] {
  border-color: var(--accent-soft);
  background: var(--surface-2);
  font-weight: 700;
}
.fm-palette-dot {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  flex: none;
  border: 1px solid var(--border-strong);
}
.fm-modes {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4px;
  background: var(--surface-2);
  border-radius: var(--radius-sm);
  padding: 4px;
}
.fm-mode-opt {
  border: none;
  background: none;
  color: var(--text-muted);
  border-radius: 6px;
  padding: 6px 0;
  font: 600 12px/1.2 var(--font-body);
  cursor: pointer;
  transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
/* :not() guard so the selected state does not depend on source order. Both this and the
   [aria-pressed="true"] rule below are specificity (0,2,0); the selected state was winning only
   because it is written second, which is one reorder away from silently breaking. Deferred since
   2026-08-21 because it costs a ?v=N sweep across every page, and ridden along with the switcher
   icon fix rather than paying for that sweep twice. */
.fm-mode-opt:not([aria-pressed="true"]):hover { color: var(--text); }
.fm-mode-opt[aria-pressed="true"] {
  background: var(--accent-strong);
  color: var(--on-accent);
}

/* ---------- buttons ---------- */
.btn {
  display: inline-block;
  padding: 0.85rem 1.75rem;
  border-radius: var(--radius-sm);
  border: none;
  font-family: var(--font-body);
  font-size: 0.95rem;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease),
              background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.btn:hover { transform: translateY(-2px); }
.btn:active { transform: scale(0.98); }
.btn-primary {
  background: var(--accent-strong);
  color: var(--on-accent);
}
.btn-primary:hover { box-shadow: 0 8px 20px var(--accent-glow); }
.btn-secondary {
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border-strong);
}
.btn-secondary:hover { border-color: var(--accent-soft); color: var(--accent); }

/* Content links carry an underline AT REST, not only on hover.
   The old rule animated a gradient bar from 0% to 100% width, which left the
   resting state with no underline at all — inline prose links were then told
   apart from body text by colour alone (WCAG 2.2 SC 1.4.1, axe
   "link-in-text-block"). The hover emphasis is kept by moving the animation
   onto the underline's own colour instead of its width. */
/* A link that is a BUTTON or a CARD is not prose. It already carries its own
   background, border and colour, and this rule was overriding all of it:
   `.fm-prose a` is (0,1,1) and `.btn-primary` only (0,1,0), so the button on
   /about/ rendered var(--accent) text on a var(--accent-strong) fill — 1:1,
   completely invisible in dark mode. Measured on the deployed site: 17 such
   links across 9 pages. Excluding them here means the component keeps its own
   styling instead of fighting for it. */
.fm-link,
.fm-prose a:not(:is(.btn, .cta, .card, .fm-card, .card-link, .chip,
                    [class*="-btn"], [class*="btn-"],
                    [class*="-card"], [class*="card-"],
                    [class*="-chip"], [class*="-stretch"], .video-link, .hub-link)):not(:where(.series-nav a, .post-nav a, .cta-buttons a, .scroll-nav a)) {
  color: var(--accent);
  text-decoration: underline;
  text-decoration-thickness: 1.5px;
  text-underline-offset: 0.18em;
  text-decoration-color: color-mix(in oklab, currentColor 45%, transparent);
  transition: text-decoration-color var(--dur) var(--ease);
}
.fm-link:hover,
.fm-prose a:not(:is(.btn, .cta, .card, .fm-card, .card-link, .chip,
                    [class*="-btn"], [class*="btn-"],
                    [class*="-card"], [class*="card-"],
                    [class*="-chip"], [class*="-stretch"], .video-link, .hub-link)):not(:where(.series-nav a, .post-nav a, .cta-buttons a, .scroll-nav a)):hover { text-decoration-color: currentColor; }
/* Navigation regions are exempt. WCAG 1.4.1 is about a link buried IN a block
   of text, where colour alone has to tell you it is a link; a table of contents
   or a breadcrumb is a list of links and nothing else, so underlining every row
   adds noise and removes no ambiguity. Keep this list tight — anything that
   mixes links into running prose must stay underlined. */
.fm-prose nav a,
.fm-prose .toc a,
.fm-prose a.fm-card,
.fm-prose a.card { text-decoration: none; }

/* ---------- cards ---------- */
.fm-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  transition: transform var(--dur) var(--ease), border-color var(--dur) var(--ease),
              box-shadow var(--dur) var(--ease);
}
.fm-card:hover {
  transform: translateY(-4px);
  border-color: var(--accent-soft);
  box-shadow: var(--shadow-2);
}

/* ---------- breadcrumbs ---------- */
/* `width: 100%` is load-bearing, not decoration. 64 pages give <body> a
   `display: flex; flex-direction: column` sticky-footer layout, which makes
   .breadcrumbs a flex item. On a column container the CROSS axis is horizontal,
   and per CSS Flexbox 1 §9.6 an item only stretches when NEITHER cross-axis
   margin is auto — so `margin: 0 auto` cancelled the stretch, the box collapsed
   to fit-content (measured 261px in a 1425px viewport) and the auto margins
   then centred it. In block layout the identical rule fills the container and
   the autos resolve to 0, which is why it looked correct everywhere else.
   Giving the box a definite width restores one behaviour for both layouts.
   box-sizing is set here too: fm.css declares no universal reset, so the 32px
   padding must be kept inside the 100%. */
.breadcrumbs {
  box-sizing: border-box;
  width: 100%;
  max-width: var(--wide-width);
  margin: 0 auto;
  padding: 14px 32px 0;
  font-size: 0.82rem;
  color: var(--text-muted);
}
.breadcrumbs a { color: var(--text-muted); text-decoration: none; }

/* ---------- collapse the stacked top padding above an <h1> ----------
   Three boxes each contributed their own top padding and they compounded: the
   breadcrumb row, the content wrapper (.fm-prose / <main>, 24-72px) and the
   .hero itself (29-63px). Measured across all 216 posts: a median 55px and up
   to 135px of dead air between the breadcrumb and the headline, 147 posts over
   45px. The wrapper is the one that yields — the hero keeps its padding, so a
   page with no hero is untouched and the rhythm inside the hero is unchanged.

   The specificity is raised on purpose. Page <style> blocks are parsed AFTER
   fm.css, so a plain `.fm-prose { padding-top: 0 }` here would lose to every
   page's own `.fm-prose { padding: 24px … }`. `body` + `:has()` outranks a
   single class without needing !important. */
body .fm-prose:has(> .hero),
body main:has(> .hero),
body article:has(> .hero),
body .container:has(> .hero),
body .wrapper:has(> .hero) { padding-top: 0; }

/* Same collapse one level down: several posts wrap the hero in <main> and put
   the padding on an inner .fm-prose, or vice versa. */
body main:has(> .fm-prose > .hero),
body main:has(> .article-wrap > .hero) { padding-top: 0; }
.breadcrumbs a:hover { color: var(--accent); }

/* ---------- scroll reveal (bound by fm.js; html.fm-js gate keeps content
   visible when JS is unavailable) ---------- */
.fm-js .reveal { opacity: 0; transform: translateY(20px); }
.fm-js .reveal.visible {
  opacity: 1;
  transform: none;
  transition: opacity var(--dur-slow) var(--ease), transform var(--dur-slow) var(--ease);
}

/* ---------- footer ---------- */
footer {
  border-top: 1px solid var(--border);
  background: var(--surface);
  margin-top: 4rem;
}
.footer-inner {
  max-width: var(--wide-width);
  margin: 0 auto;
  padding: 3rem 32px 2rem;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 2rem;
}
.footer-col h3, .footer-col h4 {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent-2);
  margin: 0 0 0.9rem;
}
.footer-col ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}
.footer-col a {
  display: block;
  color: var(--text-muted);
  text-decoration: none;
  font-size: 0.88rem;
  padding: 0.22rem 0;
  transition: color var(--dur-fast) var(--ease);
}
.footer-col a:hover { color: var(--accent); }
.footer-bottom {
  max-width: var(--wide-width);
  margin: 0 auto;
  padding: 1.2rem 32px 2rem;
  border-top: 1px solid var(--border);
  font-size: 0.82rem;
  color: var(--text-muted);
}

/* ---------- responsive ---------- */

/* NAV COLLAPSE — at 1024, not 768.
   The horizontal nav (switcher + logo + seven links) measures ~944px on every
   page. Collapsing only at 768 left the whole 769-943 band overflowing: the
   last link ran under the viewport edge and the page scrolled sideways, with
   no hamburger to fall back to. 1024 is the site's own breakpoint and clears
   the measured requirement with room to spare. Sidebar pages opt in to an
   even earlier collapse with .fm-nav-collapse-early (1200) below. */
@media (max-width: 1024px) {
  .hamburger { display: block; margin-left: auto; }
  .nav-links {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: flex-start;
    gap: 0;
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    padding: 8px 16px 16px;
    margin-left: 0;
  }
  .nav-links.open { display: flex; }
  .nav-links a { padding: 10px 0; font-size: 15px; width: 100%; }
}

@media (max-width: 768px) {
  .nav-inner { padding: 12px 16px; }
  .breadcrumbs { padding: 12px 16px 0; }
  .footer-inner, .footer-bottom { padding-left: 16px; padding-right: 16px; }
}
@media (max-width: 480px) {
  html { font-size: 17px; } /* floor — never below 17px on mobile */
}

/* Pages with a persistent left sidebar (tools) opt in with
   <body class="fm-nav-collapse-early">: the nav collapses to the hamburger
   while the sidebar still eats horizontal space, instead of wrapping. */
@media (max-width: 1200px) {
  .fm-nav-collapse-early .nav-links {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: flex-start;
    gap: 0;
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    padding: 8px 16px 16px;
    margin-left: 0;
  }
  .fm-nav-collapse-early .nav-links.open { display: flex; }
  .fm-nav-collapse-early .nav-links a { padding: 10px 0; font-size: 15px; width: 100%; }
  .fm-nav-collapse-early .hamburger { display: block; margin-left: auto; }
}

/* Tools sidebar opener. In the flow (after the nav, or first in a tool's
   toolbar), never position:fixed — fixed at left:12px/top:76px it sat on the
   <h1>, Markdown Preview's Bold button and the open mobile menu. */
.sidebar-toggle-btn {
  display: none;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 10px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  color: var(--text);
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease);
}
.sidebar-toggle-btn:hover { border-color: var(--accent); }
.sidebar-toggle-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.sidebar-toggle-btn svg { width: 18px; height: 18px; fill: currentColor; }
.top-nav + .sidebar-toggle-btn { margin: 12px 0 0 max(32px, calc(50% - var(--wide-width) / 2)); }
body:has(.tools-sidebar.collapsed) .sidebar-toggle-btn { display: flex; }
@media (max-width: 1024px) {
  .sidebar-toggle-btn { display: flex; }
}
@media (max-width: 768px) {
  .top-nav + .sidebar-toggle-btn { margin-left: 16px; }
}

/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .fm-js .reveal { opacity: 1; transform: none; }
}

/* =========================================================================
   THE READING PAGE — header block, measure, and prose size.
   Rahul, 2026-09-07, pointing at four separate bands of dead air on a post,
   the /blogs/ index and a category index: "This spacing is irrelevant… This is
   a lot of spacing."

   These live in fm.css, NOT in 225 posts, because the gaps are made by
   per-page rules that all differ (.hero padding here, section.phase
   margin-top: 63px there). One rule that outranks them is the only version of
   this that stays true for pages written next month.

   SCOPED by the markers each page class already carries — [data-fm-read] on a
   post's content root, [data-fm-cards] on a category listing, .lib-search on
   /blogs/ — so tools and service pages keep their own hero rhythm.
   ========================================================================= */

body:is(:has([data-fm-read]), :has([data-fm-cards]), :has(.lib-search), :has(.deck))
  :is(.hero, .hero-section, .article-header, header.hero) {
  padding-top: 8px;
  padding-bottom: 4px;
}

/* The headline does not need a paragraph of air under it. */
body:is(:has([data-fm-read]), :has([data-fm-cards]), :has(.lib-search), :has(.deck))
  :is(.hero, .hero-section, .article-header) > h1 {
  margin-top: 0;
  margin-bottom: 8px;
}

/* SUBTITLE SPANS THE COLUMN. A 600-720px cap was set when the column was 820px;
   inside a 70vw column it wraps a two-line sentence onto four and then centres
   the ragged result. 42 posts carried such a cap. */
body:is(:has([data-fm-read]), :has([data-fm-cards]), :has(.lib-search), :has(.deck))
  :is(.hero > p, .hero-sub, .hero-subtitle, .subtitle, .hero .lead) {
  max-width: none;
}

/* The byline is the header/body boundary, so the first content block's own top
   margin is redundant on top of it — that stacking is what produced the 83px
   band measured under the byline on the service-worker post. */
body:has([data-fm-read]) .fm-byline { margin-top: 4px; margin-bottom: 10px; }
body:has([data-fm-read]) .fm-byline + * { margin-top: 14px; }

/* Bigger text to pay for the wider column: at 70vw a 19.1px measure runs long. */
body:has([data-fm-read]) :is(.fm-prose p:not(.fm-byline), .fm-prose li) { font-size: 1.18rem; }

/* THE BYLINE IS THE LEAST IMPORTANT LINE ON THE PAGE. It was being caught by the
   prose bump above and rendering at 21.24px — LARGER than the body copy it sits
   above. Rahul: "these are the least important things, so just use half of the
   font size". Author, date and read time now sit at 0.72rem against a 1.18rem
   measure, and the chips shrink with them. */
body:has([data-fm-read]) .fm-byline,
body:has([data-fm-read]) .fm-prose p.fm-byline {
  font-size: 0.72rem;
  color: var(--text-muted);
}
body:has([data-fm-read]) .fm-byline a { font-weight: 600; }

/* Not every post has a .hero — on many the <h1> is a direct child of
   .fm-prose / <main>, so the rules above never matched and the gap was made by
   <main>'s own padding-top instead. Measured 36px above the h1 and 18px under
   it on the service-worker post after the .hero rules had already landed. */
body:has([data-fm-read]) :is(main, article).fm-prose { padding-top: 10px; }
body:has([data-fm-read]) main > article.fm-prose { padding-top: 0; }
body:has([data-fm-read]) :is(main, article, .fm-prose) > h1 { margin-bottom: 8px; }
/* the paragraph that sits directly above the byline is the subtitle */
body:has([data-fm-read]) :is(p, .subtitle):has(+ .fm-byline) { margin-bottom: 8px; }
/* …and on many posts the padding is on a bare <main> that carries no class at
   all, which the .fm-prose selector above cannot see. This is the last of the
   four bands: 36px between the breadcrumb and the <h1>. */
body:has([data-fm-read]) > main,
body:has([data-fm-read]) main:has(> article.fm-prose),
body:has([data-fm-read]) main[data-fm-read] { padding-top: 10px; }

/* The subtitle sits directly above body copy that is now 1.18rem, so at the same
   size the two read as one block. Smaller, and the hierarchy returns. */
body:is(:has([data-fm-read]), :has([data-fm-cards]), :has(.lib-search), :has(.deck))
  :is(.hero > p:not(.fm-byline), .hero-sub, .hero-subtitle, .subtitle) {
  font-size: 1.02rem;
  line-height: 1.55;
}
body:has([data-fm-read]) :is(p, .subtitle):has(+ .fm-byline) { font-size: 1.02rem; }

/* Date and read time as quiet chips: they are metadata, not prose, and at body
   size in a body font they read as the first sentence of the article. Subtle by
   instruction — surface-2 with a hairline, never an accent fill. */
.fm-byline :is(time, .fm-readtime) {
  display: inline-block;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 1px 10px;
  font-size: 0.82em;
  font-family: var(--font-sans);
  color: var(--text-muted);
  vertical-align: middle;
}
/* the separators are redundant once each value is boxed */
.fm-byline .sep { display: none; }
.fm-byline :is(time, .fm-readtime) + :is(time, .fm-readtime) { margin-left: 6px; }
.fm-byline > a { margin-right: 6px; }

/* ---- the switch must never sit on top of text ----
   The listing switch is absolutely positioned above 1100px, so it only works
   while the hero copy leaves a gutter. Making subtitles full width removed that
   gutter and the switch landed on the lead (caught on /blogs/react/). The lead
   is centred, so capping it symmetrically keeps it centred AND keeps ~200px
   clear on each side — more than the 178px the switch occupies.
   ANY future change to hero width must re-run the collision sweep. */
@media (min-width: 1101px) {
  /* CENTRED heroes: cap symmetrically so the lead stays centred and still clears
     the switch on both sides. */
  body:has([data-fm-cards]) :is(.hero > p:not(.fm-byline), .hero-sub) {
    max-width: calc(100% - 420px);
    margin-left: auto;
    margin-right: auto;
  }
  /* LEFT-ALIGNED heroes (.subtitle on backend/database/nodejs) must NOT get auto
     margins — that centred a 408px strip at x=509 under an h1 sitting at x=299,
     which is the misalignment Rahul caught. Padding reserves the same gutter and
     is invisible on left-aligned text. */
  body:has([data-fm-cards]) .subtitle {
    max-width: none;
    margin-left: 0;
    margin-right: 0;
    padding-right: 220px;
  }
}

/* The header/body boundary needs air — 4px read as one block. */
body:has([data-fm-cards]) .fmc-bar { margin-top: 18px; }
body:has([data-fm-cards]) .cards { margin-top: 6px; }
body:has([data-fm-read]) .fm-byline + * { margin-top: 26px; }

/* 28 posts had no column cap at all and ran to 99% of the viewport. The content
   root is the one element every post is guaranteed to have, so the measure is
   pinned there rather than on whichever wrapper that post happens to use. */
body [data-fm-read] {
  max-width: var(--max-width);
  margin-inline: auto;
  box-sizing: border-box;
}

/* 70vw is a column on a desktop and a gutter with words in it on a phone: at
   390px it computes to a 273px measure. The share steps up as the viewport
   shrinks so the text always has room, using the site's own breakpoints. */
@media (max-width: 1100px) { :root { --max-width: 84vw; } }
@media (max-width: 768px)  { :root { --max-width: 92vw; } }
@media (max-width: 480px)  { :root { --max-width: 100%; } }
/* Posts whose <h1> is a plain child of .fm-prose inherit the prose h1's top
   margin (measured 31.6px on the networking guides against 0 elsewhere), which
   put their breadcrumb→h1 gap at 42px while every other post sat at 18px. */
body:has([data-fm-read]) .fm-prose > h1:first-child,
body:has([data-fm-read]) .fm-prose > h1 { margin-top: 0; }
/* 18 posts predate .fm-byline and carry their own meta row (author, date and
   read time on one line). Same job, same weight — otherwise those 18 shout
   while the other 207 whisper. */
body:has([data-fm-read]) :is(.article-meta, .post-meta, p.meta) {
  font-size: 0.72rem;
  color: var(--text-muted);
}

/* The networking index has no .hero and no [data-fm-cards] — its <h1> is a plain
   child of <main>, so the header rules above could not reach it and it sat at a
   66px breadcrumb→h1 gap against 8-18px on every other category index. */
body:has(.deck) > main,
body:has(.deck) main:has(> h1) { padding-top: 10px; }
body:has(.deck) main > h1 { margin-top: 0; }

/* ---- phone-width overflow guards ----
   Measured on all 225 posts at 390px: 12 overflowed. Three causes, two of which
   are general and belong here rather than in a dozen page stylesheets.
   1. Unbreakable inline runs — a long identifier in a <span>, a code token — set
      a min-content floor on their block that no amount of wrapping can shrink
      (span.muted pushed 114px past the viewport on claude-md-ultimate-guide).
      fm.css already does this for `:not(pre) > code`; prose text needs it too.
   2. Tables wider than a phone. Scrolling on their own axis is the fix; the page
      must not scroll instead (table.compare-table, 16px, on webvitals/tbt).
   The third cause — hand-built fixed-width blocks like .why-copy and
   .widget.breakout — is genuinely per-page and is NOT papered over here. */
@media (max-width: 768px) {
  body:has([data-fm-read]) :is(p, li, dd, dt, td, th, figcaption, blockquote, span) {
    overflow-wrap: anywhere;
  }
  /* Table cells break only a word too long for any column. `anywhere` also
     shrank the column's min-content, so cells split ordinary words mid-word
     ("Packstatio/n") instead of letting the table scroll on its own axis below. */
  body:has([data-fm-read]) :is(td, th),
  body:has([data-fm-read]) :is(td, th) * {
    overflow-wrap: break-word;
  }
  body:has([data-fm-read]) table {
    display: block;
    overflow-x: auto;
    max-width: 100%;
  }
}
