/* Firm86 — one stylesheet, no build step.
   Owned by frontend-dev. Writers do not add <style> blocks to pages; if a page needs
   something this file does not have, it goes in here once and every page gets it.
   Verify changes in a real browser at 390px and 1440px, not by reasoning about specificity.
   That line said 375px until 2026-09-01 and the number was stale, not wrong-headed: seo/MEASURING.md
   settled on 390 as this site's narrow width, and the way to get a true 390 on this machine is a
   405px iframe, because the classic scrollbar is 15px and resize_page(390,...) returns 502. The
   comment on .fence below still says "a true 375" and is left as written - it records what that
   pass actually measured at, and rewriting it would make a reading look like it was taken at a
   width it was not. Both widths sit on the same side of the only breakpoint (860px), so nothing
   measured at 375 is invalidated by this. */

:root {
  --ink:        #0E1116;
  --ink-2:      #333B47;
  --muted:      #616C7A;
  --line:       #E2E6EB;
  --line-2:     #C9D1DA;
  /* Non-text UI boundaries, WCAG 1.4.11, 3:1 minimum. --line and --line-2 are decorative
     hairlines around cards, chips and the fence; they stay light on purpose and darkening
     them would repaint every card and fence box on 40 pages. Use these instead wherever the
     border is the only thing marking an element as a control. Measured, not reasoned. */
  --ui-edge:     #818C9A;   /* 3.41:1 on --paper */
  --ui-edge-ink: #606A7A;   /* 3.46:1 on --ink, 3.35:1 on --code-bg */
  --paper:      #FFFFFF;
  --tint:       #F5F7F9;
  --tint-2:     #EDF1F5;

  --accent:     #2B4BFF;
  --accent-ink: #1B33C4;
  --accent-bg:  #EEF1FF;

  /* Ratios measured against --paper AND against --tint, which is the worst light surface
     these can land on (th, .bg-tint, .fence all paint --tint). The old --pass #0F8A4D was
     4.41:1 on paper and 4.11:1 on tint; the old --warn #9A6B00 was 4.69:1 and 4.37:1. Both
     were under the 4.5:1 body minimum, --pass on white as well. Nothing renders these yet -
     fixed now so the first page to use one does not ship the defect. --fail already passed
     on both surfaces and is left alone; the three stay balanced in weight. */
  --pass:       #0C7A44;   /* 5.41:1 on --paper, 5.04:1 on --tint */
  --fail:       #C9333A;   /* 5.23:1 on --paper, 4.87:1 on --tint */
  --warn:       #8A5F00;   /* 5.65:1 on --paper, 5.26:1 on --tint */

  --code-bg:    #11151C;
  --code-fg:    #E6EAF0;
  --code-dim:   #8A94A6;

  --sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  --display: "Space Grotesk", var(--sans);
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  --wide: 1120px;
  --prose: 68ch;
  --radius: 10px;
}

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

/* WCAG 2.4.1, Level A. Off-screen until focused rather than display:none, because a hidden element
   is not focusable and a skip link that cannot be reached by Tab is not a skip link. It is the
   first thing in the body, so it is the first stop -- ahead of the brand and the five nav items it
   exists to skip. `top` animates rather than `display` so the move is visible; a user who cannot
   see where focus went has learned nothing from it arriving.
   The target carries tabindex="-1": without it several browsers scroll to #main and leave focus in
   the header, so the next Tab walks straight back into the nav that was just skipped. It never
   enters the tab order itself, and :focus is unstyled on it for the same reason -- nobody clicked
   it, and a focus ring around the whole page is noise. */
.skip-link {
  position: absolute; left: 8px; top: -64px; z-index: 100;
  background: var(--paper); color: var(--accent-ink);
  border: 2px solid var(--accent); border-radius: var(--radius);
  padding: 10px 16px; font-weight: 600; text-decoration: none;
  transition: top .12s ease-out;
}
.skip-link:focus { top: 8px; }
main:focus { outline: none; }

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

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 17px;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
}

/* ---------- rhythm ---------- */

h1, h2, h3, h4 { font-family: var(--display); line-height: 1.2; letter-spacing: -0.02em; margin: 0 0 .5em; }
h1 { font-size: clamp(2rem, 4.6vw, 3.1rem); font-weight: 700; }
h2 { font-size: clamp(1.5rem, 2.8vw, 2rem); font-weight: 700; margin-top: 2.4em; }
h3 { font-size: 1.2rem; font-weight: 600; margin-top: 1.8em; }
h4 { font-size: 1rem; font-weight: 600; margin-top: 1.4em; }

p, ul, ol, table, pre, blockquote { margin: 0 0 1.15em; }

/* A package name is one word to a line-breaker. This site's prose is full of them --
   `@executeautomation/playwright-mcp-server` is forty characters with nothing to break on, and bare
   URLs like `github.com/microsoft/playwright-mcp` are the same shape -- and at a 280px viewport
   three blog pages pushed the whole document sideways on tokens like those. Measured, not guessed:
   the offender was never an element sticking out, which is why it took looking for content wider
   than its own box to find. break-word breaks such a word only when it cannot fit, so ordinary
   sentences are untouched.

   NOT on pre, and the exclusion is the point: this stylesheet says a few lines down that long code
   lines must scroll and must not wrap, because a wrapped sample is a sample that no longer says
   what it ran as. `white-space: pre` already refuses the break, so this is belt and braces against
   a future rule that relaxes it. */
p, li, dd, dt, td, th, summary, blockquote, h1, h2, h3, h4,
.code-note { overflow-wrap: break-word; }   /* the caption carries a file path -- `Playwright 1.62 ·
                                               src/components/FilterPopover.story.tsx` -- which is
                                               one unbreakable word and was the last thing pushing a
                                               page sideways at 280px. It is a caption, not a
                                               sample: wrapping it costs nothing. */
pre, pre code { overflow-wrap: normal; word-break: normal; }
li { margin-bottom: .4em; }
ul, ol { padding-left: 1.3em; }
strong { font-weight: 600; }

a { color: var(--accent-ink); text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; }
a:hover { color: var(--accent); }

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

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

.wrap { width: 100%; max-width: var(--wide); margin: 0 auto; padding: 0 24px; }
.prose { max-width: var(--prose); }
.prose h2, .prose h3 { max-width: 100%; }

section { padding: 64px 0; }
section.tight { padding: 40px 0; }
.bg-tint { background: var(--tint); }
.bg-ink { background: var(--ink); color: #EDF1F5; }
.bg-ink h2, .bg-ink h3 { color: #fff; }
/* :not(.btn) is load-bearing. Without it this selector is (0,1,1) and outranks
   .btn-primary at (0,1,0), repainting the primary CTA #B9C6FF on #2B4BFF -
   measured 3.54:1, a fail. A dark band recolours prose links, never buttons. */
.bg-ink a:not(.btn), .cta a:not(.btn) { color: #B9C6FF; }
/* And the hover has to be restated here, at (0,3,1), because the rule above is (0,2,1) and
   the bare `a:hover` at (0,1,1) never reached these links - measured no colour change at all
   on a dark band while control links on the same page did change. Direction matches the light
   theme, where hover moves off --accent-ink toward the more saturated --accent: this is that
   same move, as a tint of --accent rather than a lift toward white. Going to #fff would have
   read as the link losing its colour, since the band's body text is already #EDF1F5.
   The :not(.btn) carries over for the reason in the comment above - drop it here and the
   hovered primary CTA turns #B9C6FF on #1B33C4. */
.bg-ink a:not(.btn):hover, .cta a:not(.btn):hover { color: #8FA6FF; }
.bg-ink :not(pre) > code, .cta :not(pre) > code {
  background: rgba(255,255,255,.10); color: var(--code-fg);
}

hr { border: 0; border-top: 1px solid var(--line); margin: 40px 0; }

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

.site-header {
  border-bottom: 1px solid var(--line);
  position: sticky; top: 0; z-index: 20;
  background: rgba(255,255,255,.92);
  backdrop-filter: saturate(1.4) blur(8px);
}
.site-header .wrap { display: flex; align-items: center; gap: 24px; min-height: 64px; }

.brand {
  font-family: var(--display); font-weight: 700; font-size: 1.15rem;
  color: var(--ink); text-decoration: none; letter-spacing: -0.03em;
  display: inline-flex; align-items: center; gap: 8px;
}
.brand .mark {
  width: 26px; height: 26px; border-radius: 7px; background: var(--ink);
  color: #fff; font-family: var(--mono); font-size: .72rem; font-weight: 500;
  display: inline-flex; align-items: center; justify-content: center;
}

.nav { display: flex; gap: 22px; margin-left: auto; align-items: center; flex-wrap: wrap; }
.nav a { color: var(--ink-2); text-decoration: none; font-size: .95rem; font-weight: 500; }
.nav a:hover, .nav a[aria-current="page"] { color: var(--accent-ink); }

/* ---------- buttons ---------- */

.btn {
  display: inline-block; padding: 12px 22px; border-radius: var(--radius);
  font-weight: 600; font-size: .97rem; text-decoration: none; border: 1px solid transparent;
  transition: background .12s ease, border-color .12s ease;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-ink); color: #fff; }
/* The border is the only thing marking a ghost button as a button, so it is a non-text UI
   boundary and owes 3:1 (WCAG 1.4.11). It was --line-2, measured 1.54:1 on --paper. The token
   itself is not the bug - it is right for the card and fence hairlines that also use it - so
   the button moved to --ui-edge and --line-2 stayed where it was. */
.btn-ghost { border-color: var(--ui-edge); color: var(--ink); background: var(--paper); }
.btn-ghost:hover { border-color: var(--ink); color: var(--ink); }
/* On a dark band the ghost button is an outline, not a pill. Every property the light
   variant above sets has to be answered here - `background` included. The .cta copy of
   this rule omitted it, so the button rendered #fff on #fff, 1.00:1, on 34 pages. One
   rule, both hooks, so the next dark band cannot inherit half of it. */
/* Same 3:1 boundary on the dark side: #3C4654 measured 1.98:1 on --ink. */
.bg-ink .btn-ghost, .cta .btn-ghost {
  background: transparent; border-color: var(--ui-edge-ink); color: #fff;
}
.bg-ink .btn-ghost:hover, .cta .btn-ghost:hover {
  background: transparent; border-color: #fff; color: #fff;
}
.btn-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
/* .btn-row had margin-top and no margin-bottom, and the generic `p, ul, ol, table, pre,
   blockquote { margin: 0 0 1.15em }` above gives the following <p> a margin-top of 0. So where
   a button row is followed by body copy the two boxes touch: measured gap of exactly 0.00px on
   11 of the 16 service pages, at 390 and at 1440 alike. It reads worst at 390, where the ghost
   button on the training page is 266.63px in a 342px column and does not fill the line, so the
   sentence underneath starts directly below a button border and looks like a caption on it.
   Measured after: 28.00px on all 11, at both widths.

   :not(:last-child) is the whole rule and it is not tidiness. A plain
   `.btn-row { margin-bottom: 28px }` was BUILT AND MEASURED as a third tree rather than argued
   about, because 63 of the 74 button rows on this site are the last child of their parent:
     - 56 of those sit in .cta, which has padding-bottom, so nothing collapses and the naive
       version pads the inside of every CTA band on all 56 pages - parent box +28.00px, 56/56.
     - 7 sit last in a hero .wrap, which has no vertical padding, so the margin collapses out
       of .wrap and grows the hero instead. index.html carries two of those and moved +84px.
     The naive rule changed the document height of 56 pages out of 56, 2072px in total, to fix
     a gap on 11. This rule changes 14 pages and 388px. Both numbers are at 390 and repeat at
     1440.
   The 11 followers are all <p class="prose"> today, so 28px lands against a margin-top of 0.
   If a future page puts an <h2> after a button row, that h2's own 2.4em margin-top is larger
   and wins the collapse, which is why this is a margin on .btn-row and not a `.btn-row + *`
   margin-top - the sibling form would have clamped the h2 down to 28px instead. Not measured;
   there is no such page yet. Say so rather than implying it was checked. */
.btn-row:not(:last-child) { margin-bottom: 28px; }

/* ---------- hero ---------- */

.hero { padding: 88px 0 64px; }
.hero h1 { max-width: 20ch; }
.hero .lede { font-size: 1.2rem; color: var(--ink-2); max-width: 60ch; }

.eyebrow {
  font-family: var(--mono); font-size: .78rem; letter-spacing: .08em; text-transform: uppercase;
  color: var(--accent-ink); margin: 0 0 14px;
}

/* ---------- the answer box: first thing on every service page and article ---------- */

.answer {
  border-left: 3px solid var(--accent);
  background: var(--accent-bg);
  padding: 20px 24px; border-radius: 0 var(--radius) var(--radius) 0;
  margin: 0 0 32px;
}
.answer p:last-child { margin-bottom: 0; }
.answer .label {
  font-family: var(--mono); font-size: .74rem; text-transform: uppercase; letter-spacing: .08em;
  color: var(--accent-ink); display: block; margin-bottom: 6px;
}

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

/* `min(Npx, 100%)` and not a bare `Npx`, because minmax() treats a length floor as a hard one: a
   track narrower than the floor is not allowed, so the grid grows past its container instead and
   the page scrolls sideways. Measured in Chrome, not reasoned about -- `.grid.two` pushed
   playwright-training.html 19px wide at a 360px viewport and 59px at 320px, and it stopped only
   above about 388px. That is every phone narrower than an iPhone 12: an SE at 375 saw it. Wrapping
   the floor in min() says "340px, or the whole container if that is less", which is identical above
   the threshold and collapses to one column below it.

   `.grid`, with its 280px floor, was one pixel from the same fault and is fixed the same way rather
   than left as the next thing to trip. Its 280px track ends at x=304 in a 320px viewport, so the
   overflow lands inside the 24px right padding and nothing scrolls; at 280px it does overflow, by
   39px. Both numbers are measured. */
.grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }
.grid.two { grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr)); }

.card {
  border: 1px solid var(--line); border-radius: var(--radius);
  padding: 24px; background: var(--paper);
}
.card h3 { margin-top: 0; }
.card p:last-child { margin-bottom: 0; }
.card a.card-link { text-decoration: none; }
.card-link h3 { color: var(--ink); }
.card:hover { border-color: var(--line-2); }

.card-list { list-style: none; padding: 0; margin: 0; }
.card-list li { border-top: 1px solid var(--line); padding: 18px 0; margin: 0; }
.card-list li:last-child { border-bottom: 1px solid var(--line); }
.card-list h3 { margin: 0 0 4px; font-size: 1.08rem; }
.card-list p { margin: 0; color: var(--muted); font-size: .95rem; }

/* ---------- code ---------- */

/* The scrollbar has to belong to the dark box. Without this, Chrome on Windows paints the
   opaque light classic scrollbar inside every code block that overflows - measured 15px of
   the block's height, over the bottom rounded corners, on a --code-bg of #11151C.
   color-scheme is the fallback that themes it dark; scrollbar-color is what actually names
   the two colours; scrollbar-width trims the gutter from 15px to about 8px. The thumb is
   --ui-edge-ink at 3.35:1 on --code-bg, because a scrollbar thumb is a control too.
   overflow-x: auto stays - long lines must scroll, they must not wrap. */
pre {
  background: var(--code-bg); color: var(--code-fg);
  padding: 18px 20px; border-radius: var(--radius);
  overflow-x: auto; font-size: .88rem; line-height: 1.6;
  color-scheme: dark;
  scrollbar-width: thin;
  scrollbar-color: var(--ui-edge-ink) var(--code-bg);
}
/* Code breaks out of the prose measure, because the measure is set for sentences and the samples
   are the product. Measured in Chrome at 1440 before this rule: `.wrap.prose` is one element, so
   --prose (68ch) beats --wide on the same box and the column is 729px centred, leaving 711px of
   empty margin either side; a code line got 681px, which is 91 characters at the 12.39px the mono
   actually computes to. 54 of the site's 317 blocks had a line past that and scrolled sideways, on
   26 pages. At the wrap measure a line gets 138 characters, which cures 50 of the 54 and 23 of the
   26 pages, and changes not one byte of a sample.

   The four that still scroll are genuinely long -- 145 to 200 characters -- and they scroll, as the
   rule above says they must: a sample that wraps is a sample that lies about what was run.

   The width is capped by the viewport as well as by --wide, so the expression collapses to the
   parent's own width on narrow screens: at 800px it resolves to 752px, the prose box is 752px, the
   negative margin computes to zero and nothing moves. That is why there is no media query here.
   `100%` in the margin is the prose content box, not the viewport. Verified in Chrome at 1440,
   1100, 700 and 502: no page-level horizontal scroll at any of them, and the block stays centred
   on the column.

   ONE KNOWN IMPRECISION, measured rather than left to be rediscovered: below about 1168px, where
   the 100vw term is the one that binds, the block sits 8px closer to each edge than the paragraphs
   do. 100vw counts the scrollbar and CSS has no unit for the client width, so the expression
   overshoots by exactly half a classic 16px scrollbar. It is 8px, it never causes overflow, and it
   is zero on any device with overlay scrollbars, which is every phone. Removing it would mean
   splitting .wrap and .prose into two elements across 56 pages, and that is a worse trade. */
/* Scoped to `.wrap.prose` and not to `.prose`, because five different containers carry that class
   -- `wrap prose`, `lede prose`, `answer prose`, `steps prose` and a bare `prose`. All 317 code
   blocks and all 272 captions are in `wrap prose` today, and a block that broke out of the tinted
   answer box would tear a bordered card in half. Counted before narrowing it. */
:root { --code-measure: min(calc(var(--wide) - 48px), calc(100vw - 48px)); }
.wrap.prose > pre,
.wrap.prose > .code-note {     /* the caption travels with the block it names -- 266 of the 272
                                  sit immediately after a </pre>, and a caption left behind at the
                                  prose edge under a block that starts 200px further out reads as
                                  belonging to nothing. Selector is (0,2,0) so the margin-left here
                                  beats the `margin` shorthand in the .code-note rule below, which
                                  keeps its own top and bottom. */
  width: var(--code-measure);
  margin-left: calc((100% - var(--code-measure)) / 2);
}
pre code { font-family: var(--mono); white-space: pre; }
code {
  font-family: var(--mono); font-size: .88em;
  background: var(--tint-2); padding: .12em .35em; border-radius: 4px;
}
pre code { background: none; padding: 0; }
/* A dotted call chain is one unbreakable token and measured 588px in a 342px column.
   Scoped off `pre`, which also keeps `white-space: pre` and so never wraps; `td code`
   keeps `nowrap` and scrolls inside .table-scroll. Both stay as they are. */
:not(pre) > code { overflow-wrap: anywhere; }

.code-note {
  font-family: var(--mono); font-size: .76rem; color: var(--muted);
  margin: -6px 0 20px; display: block;
}
/* .code-note is .76rem, which computes to 12.16px, and the `code` rule above is .88em, which
   multiplied it again to 10.7008px - the smallest text anywhere on this site. 62 of these on
   11 pages. The compounding was nobody's decision; it is two independent relative sizes meeting.
   Measured after: 12.16px on all 62, at 390 and at 1440.

   1em rather than a smaller number, and the reason is in the markup rather than in taste.
   .code-note already sets font-family: var(--mono), so a <code> inside one contributes only a
   --tint-2 chip and padding; the size change was never the point of wrapping it. The stamps
   prove this by being inconsistent about it: `<code>tests/dashboard-a11y.spec.ts</code>` on the
   axe page and a bare `tests/collision.spec.ts` on the test-data page are the same kind of
   token, and before this rule they rendered at two different sizes, 10.70 and 12.16. They now
   render alike, which is what the markup was always implying.

   Checked against the worry that this makes the stamp compete with body copy - it does not, and
   these are the numbers rather than an opinion. At 1440 the stamp stays 12.16px against 17px
   body prose and 14.96px for inline <code> in that prose; at 390 it is 12.16px against 16px and
   14.08px. The note's own size is untouched, so the block stays subordinate to the sample above
   it; only the chip inside it stops shrinking.
   Cost, stated because it is real: four notes gain one line at 12.16px x 1.65 = 20.06px each -
   at 390 on the axe, docker (two) and mcp-server pages, at 1440 on docker, interview-questions
   and mcp-server (two). Nothing reflows sideways: document scrollWidth is unchanged on all 56
   pages at both widths.
   Contrast is unchanged and was NOT fixed here: --muted #616C7A on --tint-2 #EDF1F5 measures
   4.70:1, composited, on all 62. That passes 1.4.3 at any size and it is the tightest ratio on
   the site. Left alone because moving --muted repaints crumbs, post-meta, card-list and the
   whole footer, which is a separate change with its own measurements. */
.code-note code { font-size: 1em; }

/* ---------- tables ---------- */

.table-scroll { overflow-x: auto; margin: 0 0 1.4em; }
table { border-collapse: collapse; width: 100%; font-size: .95rem; }
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { font-family: var(--display); font-weight: 600; font-size: .88rem; background: var(--tint); white-space: nowrap; }
td code { white-space: nowrap; }

/* ---------- status words: used for pass/fail semantics, never for decoration ---------- */

.pass { color: var(--pass); font-weight: 600; }
.fail { color: var(--fail); font-weight: 600; }
.warn { color: var(--warn); font-weight: 600; }

.chip {
  display: inline-block; font-family: var(--mono); font-size: .74rem;
  padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line-2); color: var(--muted);
}

/* ---------- the fence: what we do not do ---------- */

.fence { border: 1px solid var(--line-2); border-radius: var(--radius); padding: 24px; background: var(--tint); }
/* The fence heading is an <h2>: it is a top-level section of the page it sits on, and an <h3>
   directly under the <h1> skipped a level. Both selectors are kept so the promotion changed the
   outline and nothing else - measured rather than assumed: with the rule reverted and the heading
   put back to <h3>, every number matches to 0.01px at 1440 and at a true 375, while DELETING the
   rule outright moves the heading to 32px/700 and the document by 98px, which is what makes that
   null mean anything.
   Two corrections to what was first written here, both found the same day:
   - `.fence` is on 19 pages, not one. The conclusion survives for a different reason: index.html
     is the only fence containing a heading at all, so this rule styles one element site-wide.
     `.fence h3` matches nothing today and is kept only to make that explicit.
   - The h3 under the h1 was NOT the site's only skipped heading level, as this comment first
     claimed. Every page stepped h2 -> h4 into the shared footer, whose column labels were <h4>.
     CLOSED the same evening: 224 of them across 56 pages became <h3>, the rule at the bottom of
     this file was renamed with them, and templates/blog.html and templates/service.html went in
     the same pass so page 57 does not put it back. A scan of all 56 pages now finds no skipped
     level anywhere. The number in this bullet said 55 for about an hour, because S15 shipped
     between the measurement and the fix -- which is the smaller lesson: a count of pages is a
     reading taken at a time, and this repository adds pages.
   That is three corrections to one comment in one day. Leaving the trail rather than rewriting it
   flat, because what each correction was WRONG about is the useful part: one guessed a scope, one
   overstated a finding, and this one went stale while the fix it described was being made. */
.fence h2, .fence h3 { font-size: 1.2rem; font-weight: 600; margin-top: 0; }
.fence ul { margin-bottom: 0; }

/* ---------- steps ---------- */

.steps { list-style: none; counter-reset: step; padding: 0; }
.steps li { counter-increment: step; padding-left: 46px; position: relative; margin-bottom: 22px; }
.steps li::before {
  content: counter(step); position: absolute; left: 0; top: 1px;
  width: 30px; height: 30px; border-radius: 8px; background: var(--ink); color: #fff;
  font-family: var(--mono); font-size: .85rem; display: flex; align-items: center; justify-content: center;
}
.steps h3 { margin: 0 0 4px; font-size: 1.05rem; }

/* ---------- FAQ ---------- */

.faq { border-top: 1px solid var(--line); }
.faq details { border-bottom: 1px solid var(--line); padding: 4px 0; }
.faq summary {
  font-family: var(--display); font-weight: 600; font-size: 1.05rem;
  padding: 16px 32px 16px 0; cursor: pointer; list-style: none; position: relative;
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after {
  content: "+"; position: absolute; right: 4px; top: 14px;
  font-family: var(--mono); font-size: 1.3rem; color: var(--muted); line-height: 1;
}
.faq details[open] summary::after { content: "–"; }
.faq details > div { padding: 0 0 16px; max-width: var(--prose); }
.faq details > div p:last-child { margin-bottom: 0; }

/* ---------- breadcrumbs, meta ---------- */

.crumbs { font-size: .87rem; color: var(--muted); margin: 28px 0 12px; }
.crumbs a { color: var(--muted); }
.post-meta { font-size: .87rem; color: var(--muted); margin: 0 0 28px; }

/* ---------- related ---------- */

.related { border-top: 1px solid var(--line); padding-top: 28px; margin-top: 48px; }
.related h2 { margin-top: 0; font-size: 1.15rem; }
.related ul { list-style: none; padding: 0; }
.related li { margin-bottom: 8px; }

/* ---------- CTA band ---------- */

.cta { background: var(--ink); color: #EDF1F5; border-radius: 14px; padding: 44px; margin: 64px 0; }
.cta h2 { color: #fff; margin-top: 0; }
.cta p { color: #B6C0CE; max-width: 56ch; }
/* .cta's button, link and inline-code treatment lives beside .bg-ink's, in one place. */

/* ---------- footer ---------- */

.site-footer { border-top: 1px solid var(--line); padding: 48px 0 64px; font-size: .93rem; color: var(--muted); }
.site-footer .cols { display: grid; gap: 32px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
/* The footer column labels are <h3>, not <h4>. Every page on the site used to step h2 -> h4 into
   this footer, which is a skipped level on 56 of 56 pages -- measured by visual-qa, then recounted
   here: 224 <h4> elements, four per page, and none anywhere in any page body. That last number is
   what made the swap safe to do mechanically rather than page by page.
   Nothing renders differently. That was first argued from the cascade and is now MEASURED: this rule
   already overrides font-size and margin, and font-weight is 600 for h3 and h4 alike.
   The argument as first written said "the only two selectors that could newly reach an h3 here" and
   named `.prose h2, .prose h3` and `.bg-ink h2, .bg-ink h3`. **There are eight** -- add `h1, h2, h3,
   h4` (65), `h3` (68), `.card h3` (198), `.card-link h3` (201), `.card-list h3` (207), `.fence h2,
   .fence h3` (286) and `.steps h3` (298). visual-qa checked all eight against every footer on all 56
   pages: zero matches, so the conclusion holds and only the reasoning was short.
   It then measured the thing itself rather than reasoning about it. Two trees, identical but for the
   224 headings and this one selector: twelve computed properties, every heading box, every column
   box, the footer box, the preceding <main> box and the document height, at 390 and at 1440, across
   seven pages and then all 56 -- identical to 0.01px, zero deviations. A third tree with this rule
   DELETED moved the document 40px at 1440 and 159px at 390 and changed six properties, which is what
   makes the null mean something rather than mean the probe was blind.
   Third correction to a comment in this file, and the second of this exact shape: a completeness
   claim made without the count behind it. The first said this was the site's only skipped heading
   level. Both times the conclusion was right and the reasoning was narrower than the claim -- which
   is the worse failure, because the next reader checks the conclusion, finds it holds, and inherits
   the argument. */
.site-footer h3 { font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; color: var(--ink-2); margin: 0 0 12px; }
.site-footer ul { list-style: none; padding: 0; }
.site-footer li { margin-bottom: 7px; }
.site-footer a { color: var(--muted); text-decoration: none; }
.site-footer a:hover { color: var(--ink); text-decoration: underline; }
.site-footer .legal { margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--line); font-size: .85rem; }

/* ---------- narrow ---------- */

@media (max-width: 860px) {
  body { font-size: 16px; }
  .hero { padding: 56px 0 40px; }
  section { padding: 48px 0; }
  .cta { padding: 32px 24px; }
  .site-header .wrap { min-height: 58px; gap: 12px; flex-wrap: wrap; padding-top: 10px; padding-bottom: 10px; }
  .nav { gap: 16px; margin-left: 0; width: 100%; }
  .nav a { font-size: .9rem; }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  * { transition: none !important; }
}

@media print {
  .site-header, .cta, .btn-row, .related { display: none; }
  body { font-size: 12pt; }
}
