/* ─────────────────────────────────────────────────────────────
   ‘Affan Najiy — portfolio
   Berkshire base (text-first, no decoration) + McMaster grid (dense tables).
   Palette carried over from tyunnie-pa: orange / black / warm white.

   Light mode only, on purpose. `color-scheme: light` below stops browsers
   auto-inverting form controls and scrollbars on a dark OS.

   Orange is a SIGNAL, not decoration. It appears only on:
   link underlines and hover, section index numbers, the table-header rule,
   nav hover, row hover fill, and focus rings.
   Never as a large fill, never as a gradient, never as a shadow.
   ───────────────────────────────────────────────────────────── */

:root {
  color-scheme: light;

  /* Surfaces */
  --bg: #faf8f5;
  /* page — warm cream */
  --surface: #ffffff;
  /* tables sit above the page */
  --surface-2: #f3f0ea;
  /* zebra stripe */
  --border: #e8e2d8;
  /* hairline rules */
  --rule: #111010;
  /* structural rules — header, footer, masthead */

  /* Ink */
  --fg: #111010;
  /* headings */
  --text: #2d2416;
  /* body copy — 14.4:1 */
  --muted: #75695a;
  /* hints, footer, nav, label column — 5.05:1 */

  /* Accent.
     --accent is the brand orange, used for RULES, BORDERS, UNDERLINES and
     FOCUS RINGS only. At 2.64:1 on cream it is not a legible text colour,
     so anything that renders orange *words* uses --accent-text (4.96:1). */
  --accent: #f97316;
  --accent-text: #b8490c;
  --accent-soft: #fff0e6;
  --accent-mid: #fed7aa;
  --accent-rgb: 249, 115, 22;

  /* One family, screen and paper. Arial on Windows, Helvetica on macOS,
     Liberation Sans (metrically identical to Arial) on Linux — all installed
     by default, so rule 1.3 holds: nothing is downloaded. Calibri, Aptos and
     Garamond are deliberately absent; they ship with Microsoft Office or not
     at all, so on a Mac or a Linux box they would silently fall back to
     something unchosen.

     The former serif/mono split is gone by decision: resume convention wants
     one or two families, and this is the family a hiring reader and an ATS
     parser both expect. Hierarchy is now carried by weight, size, letter
     spacing and case instead of by typeface. */
  --sans: Arial, "Helvetica Neue", Helvetica, "Liberation Sans", sans-serif;

  --maxw: 1000px;
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

html,
body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.55;
  -webkit-text-size-adjust: 100%;
}

/* Screen-reader-only: present to assistive tech, absent visually. */
.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;
}

/* ── No JavaScript ────────────────────────────────────────
   `.no-js` sits on <html> from the first byte and is removed by the first line
   of script.js. Anything marked `.js-only` is a control that cannot do its job
   without that file — a filter box that cannot filter, a sort header that
   cannot sort, an export button that opens nothing. Showing a dead control is
   worse than showing none: it invites a click and then says nothing about why
   nothing happened (Nielsen §1.5, §1.1). The <noscript> block in the Projects
   section says what is missing and links the same data on GitHub. */
.no-js .js-only {
  display: none !important;
}

.no-js .sort-btn {
  pointer-events: none;
}

/* ── Links that leave the page ────────────────────────────
   Every target="_blank" link carries an sr-only "(opens in a new tab)" so the
   change of context is announced rather than discovered. This is the visual
   half of the same message, for readers who get nothing from the sr-only text.
   aria-hidden by nature: it is a duplicate of words already in the link. */
a[target="_blank"]::after {
  /* \FE0E is VARIATION SELECTOR-15: "render as text, not emoji". Arial and
     Helvetica on iOS carry no glyph for this arrow, so iOS falls back to Apple
     Color Emoji and a blue emoji arrow lands mid-sentence. Every symbol drawn
     as generated content carries it. */
  content: "\2197\FE0E";
  /* ↗ */
  display: inline-block;
  margin-left: .15em;
  font-size: .85em;
  line-height: 1;
  color: var(--accent-text);
  text-decoration: none;
}

/* Dropped on paper in the print block below: nothing printed is clickable, so
   the marker would promise a tab that cannot open. */

/* ── Skip link ────────────────────────────────────────────
   Hidden until focused, then a normal visible control. */
.skip-link {
  position: absolute;
  left: .5rem;
  top: -4rem;
  z-index: 100;
  /* 40px floor: it measured 37px, and this is the first control a keyboard
     reader reaches. */
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: .6rem .9rem;
  background: var(--fg);
  color: var(--bg);
  font-family: var(--sans);
  font-size: .8rem;
  text-decoration: none;
  transition: top .12s ease;
}

.skip-link:focus {
  top: .5rem;
}

/* ── Links ────────────────────────────────────────────────
   Underline present at rest — never colour alone (Rulebook §4). */
a {
  color: var(--text);
}

a.text-link {
  color: var(--text);
  text-decoration: underline;
  text-decoration-color: var(--accent-mid);
  text-underline-offset: 2px;
  transition: color .12s ease, text-decoration-color .12s ease;
}

a.text-link:hover,
a.text-link:focus-visible {
  color: var(--accent-text);
  text-decoration-color: var(--accent);
}

/* "View PDF" and "Verify" are two destinations, not one phrase. Side by side
   they wrapped mid-phrase in the narrow Record column, so Verify sits on its
   own line under the PDF link. A rule that sets `display` overrides the
   `hidden` attribute, hence the guard. */
.cert-verify {
  display: block;
}

.cert-verify[hidden] {
  display: none;
}

/* A table scroller that JS has made focusable, because it overflows. Inset so
   the ring is not clipped by the wrap's own edge, which is where the scroll
   happens. */
.table-wrap:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), .9);
  outline-offset: -2px;
  border-radius: 2px;
}

/* A neighbouring repository, inside the detail dialog. Underlined and coloured
   like a link because it behaves like one; a button because it swaps the dialog
   rather than navigating. min-height carries the 40px tap floor without setting
   a height that would clip the text at large font sizes. */
.related-btn {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: 0;
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  cursor: pointer;
  text-decoration: underline;
  text-decoration-color: var(--accent-mid);
  text-underline-offset: 2px;
}

.related-btn:hover,
.related-btn:focus-visible {
  color: var(--accent-text);
  text-decoration-color: var(--accent);
}

/* Evidence links carry the same underline as any other link — a dotted or
   colour-only treatment would be a second link convention on one page, and
   colour alone is not a cue (Rulebook §4). They are dense, several to a cell,
   so the accent is left for hover rather than lit at rest. */
a.ev-link {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--accent-mid);
  text-underline-offset: 2px;
  transition: color .12s ease, text-decoration-color .12s ease;
}

a.ev-link:hover,
a.ev-link:focus-visible {
  color: var(--accent-text);
  text-decoration-color: var(--accent);
}

:where(a, button, [tabindex]):focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), .9);
  outline-offset: 2px;
  border-radius: 2px;
}

code {
  font-family: var(--sans);
  font-size: .9em;
  background: var(--surface-2);
  padding: .1em .35em;
  border: 1px solid var(--border);
}

/* ── Header ──────────────────────────────────────────────── */
.site-header {
  position: sticky;
  top: 0;
  z-index: 20;
  background: var(--bg);
  border-bottom: 2px solid var(--rule);
}

.site-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: .25rem 1.5rem;
  /* The inset keeps the wordmark and the nav clear of a notch or a rounded
     corner in landscape. `max()` rather than an addition, so the gutter never
     grows on a device with no cutout: `env()` resolves to 0px there and the
     1.25rem stands (UI-UX §5.12). */
  padding: 0 max(1.25rem, env(safe-area-inset-right)) 0 max(1.25rem, env(safe-area-inset-left));
  max-width: var(--maxw);
  margin: 0 auto;
}

.wordmark {
  font-family: var(--sans);
  font-weight: 700;
  letter-spacing: .08em;
  text-decoration: none;
  font-size: 1rem;
  color: var(--fg);
  /* 40px minimum tap target (Fitts's Law, Rulebook §3.7). */
  display: flex;
  align-items: center;
  min-height: 44px;
}

.wordmark:hover {
  color: var(--accent-text);
}

.site-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0 1.1rem;
  font-family: var(--sans);
  font-size: .82rem;
}

.site-nav a {
  display: flex;
  align-items: center;
  min-height: 44px;
  /* tap target, not visual size */
  text-decoration: none;
  color: var(--muted);
  border-bottom: 2px solid transparent;
  transition: color .12s ease, border-color .12s ease;
}

.site-nav a:hover,
.site-nav a:focus-visible {
  color: var(--fg);
  border-bottom-color: var(--accent);
}

/* Current section. Marked in weight AND colour AND a rule — never colour
   alone, and it answers "where am I" without the user scrolling to check. */
.site-nav a[aria-current="true"] {
  color: var(--fg);
  font-weight: 700;
  border-bottom-color: var(--accent);
}

/* A section a view mode has emptied. Marked, never removed: a nav item that
   vanishes takes the reader's map with it and gives no reason, where a marked
   one says "this exists, your current view has nothing in it" and stays
   clickable. Dimming is not the signal — the dotted rule is, so the mark does
   not depend on colour (WCAG 1.4.1). The words come from an sr-only span the
   JS writes, because a title attribute is unreachable by keyboard. */
.site-nav a[data-empty="true"] {
  color: var(--muted);
  border-bottom-style: dotted;
  border-bottom-color: var(--border);
}

/* ── Header tools ─────────────────────────────────────────
   The search control and the position readout. Pushed to the end of the
   header's flex row, and allowed to shrink to nothing before the nav does —
   the nav is the map, this is the accelerator. */
.header-tools {
  display: flex;
  align-items: center;
  gap: .75rem;
  margin: 0;
  flex: 0 0 auto;
}

.doc-position {
  font-family: var(--sans);
  font-size: .7rem;
  letter-spacing: .1em;
  color: var(--muted);
  white-space: nowrap;
  /* Reserve the widest it will ever be, so the nav does not shift sideways by a
     character every time the reader crosses a section boundary. */
  min-width: 3.2em;
  text-align: right;
}

/* Both controls below set `display`, which beats the UA rule the `hidden`
   attribute relies on — so without these they would render while claiming to be
   hidden. The Search button starts hidden and is only revealed once the dialog
   is known to work, which means without this rule a reader with no JavaScript
   gets a Search button that opens nothing. Same per-component pattern as
   .topic-chips[hidden] and .featured-band[hidden] above. */
.header-search[hidden],
.focus-exit[hidden],
.qr-btn[hidden] {
  display: none;
}

.header-search {
  font-family: var(--sans);
  font-size: .78rem;
  letter-spacing: .04em;
  color: var(--fg);
  background: var(--surface-2);
  border: 1px solid var(--border);
  cursor: pointer;
  /* 40px tap target (Rulebook §3.7) without a 40px-tall box. */
  display: flex;
  align-items: center;
  gap: .4rem;
  min-height: 40px;
  padding: 0 .7rem;
}

.header-search:hover,
.header-search:focus-visible {
  border-color: var(--accent);
  color: var(--accent-text);
}

.header-key {
  display: flex;
  gap: .2rem;
  color: var(--muted);
}

.header-key kbd {
  font-family: var(--sans);
  font-size: .66rem;
  padding: .05rem .28rem;
  border: 1px solid var(--border);
  background: var(--bg);
}

/* ── Layout ──────────────────────────────────────────────── */
main {
  max-width: var(--maxw);
  margin: 0 auto;
  /* Same safe-area treatment as the header. A phone held in landscape puts the
     notch beside the text column, and without this the first character of every
     line sits under it (UI-UX §5.12). Costs nothing anywhere else — `env()` is
     0px on a device with no inset, so `max()` returns the normal gutter. The
     `viewport-fit=cover` in the meta viewport is the half that makes `env()`
     report a real number; without it iOS letterboxes the page and reports 0. */
  padding: 0 max(1.25rem, env(safe-area-inset-right)) 0 max(1.25rem, env(safe-area-inset-left));
}

/* ── Masthead ─────────────────────────────────────────────
   One dominant shape so the page is recognised before it is read. */
.masthead {
  padding: 3.5rem 0 2.5rem;
  border-bottom: 2px solid var(--rule);
}

.masthead-name {
  font-family: var(--sans);
  font-size: clamp(2.4rem, 7vw, 4rem);
  line-height: 1.05;
  letter-spacing: -.02em;
  font-weight: 400;
  color: var(--fg);
  margin: 0;
}

.masthead-role {
  font-family: var(--sans);
  font-size: .85rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--accent-text);
  margin: .6rem 0 0 0;
}

.masthead-meta {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  gap: 1rem 2rem;
  margin: 2rem 0 0 0;
  padding-top: 1.25rem;
  border-top: 1px solid var(--border);
}

.masthead-meta>div {
  min-width: 0;
}

.masthead-meta dt {
  font-family: var(--sans);
  font-size: .68rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0 0 .2rem 0;
}

.masthead-meta dd {
  margin: 0;
  font-size: .95rem;
  overflow-wrap: anywhere;
}

/* ── Sections ────────────────────────────────────────────── */
.section {
  /* The containing block for the section's own "copy a link" button, which is
     positioned out of flow precisely so that it can sit beside the heading
     without living inside the <summary>. See .anchor-btn. */
  position: relative;
  border-bottom: 1px solid var(--border);
  padding: 2.75rem 0;
  scroll-margin-top: 5rem;
  /* sticky header must not cover the heading */
}

.section:last-of-type {
  border-bottom: none;
}

.section-title {
  font-family: var(--sans);
  font-size: .95rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--fg);
  margin: 0 0 1.25rem 0;
}

.section-title .num {
  color: var(--accent-text);
}

/* Prose runs the full content column and reflows with the viewport. Deliberate:
   a 65ch cap was tried on 2026-08-20 and reverted the same day — on this page
   the paragraphs are short, and a cap left them crunched into a narrow strip
   against a wide empty gutter, which reads worse than a long line does. The
   1000px column is the only bound, and that is the intended one. */
.section-body p {
  max-width: 100%;
  margin: 0 0 1rem 0;
}

/* Justified prose, Affan's call 2026-08-20. `.colophon-note` was already
   justified before this, so the choice existed on the page in one place. It is
   folded in here so there is one block that answers "what justifies".

   Two conditions, both measured, and the rule is wrong without either.

   `hyphens: auto` is not optional. Justify stretches the word spaces on every
   line but the last, so the wider the line the more each space gives. At the
   measures here — 108ch for bare prose, 120ch for `.note`, 144ch for
   `.colophon-note` — unhyphenated justify opens white "rivers" down the
   paragraph, which is the whole of UI-UX §6.12's objection. Hyphenation lets the
   break fall inside a word instead. Measured at 1280px with it on: widest space
   5.98px against a 4.17px natural space, so 1.43x. Mild.

   **`min-width: 641px` is the other half, and it is the one that nearly shipped
   broken.** Justify hurts a *narrow* column far more than a wide one, because
   there are fewer spaces per line to absorb the slack. Measured at 320px before
   this gate went in: widest space **27.75px** against the same 4.17px natural —
   **6.6x**, holes wide enough to read as a layout bug. 641px is the complement of
   the site's single 640px breakpoint; below it, prose is ragged-right.

   Excluded from justify, deliberately:
   - `.hint` — annotations, not prose. Ragged-right keeps them reading as
     marginalia rather than body copy.
   - `:not([class])` spares `.resume-actions` and `.fold-controls`, which are
     `<p>` rows of buttons (layout.md §1).

   Screen only. Print is a narrower column and the one-page résumé budget has no
   headroom; hyphenation moves where lines break, and lines are what that budget
   counts. To revert, delete this block — nothing else depends on it. */
@media screen and (min-width: 641px) {

  .section-body > p:not([class]),
  .section-body p.note,
  .colophon-note {
    text-align: justify;
    hyphens: auto;
    -webkit-hyphens: auto;
  }
}

.hint {
  font-family: var(--sans);
  font-size: .75rem;
  line-height: 1.5;
  color: var(--muted);
  margin: .75rem 0 0 0 !important;
}

/* Sub-heading inside a section (e.g. "Coursework projects"). */
.subhead {
  font-family: var(--sans);
  font-size: .78rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 2rem 0 .75rem 0;
}

/* A stated fact that is not a table row and not a caveat — e.g. the Dean's
   List line. Distinct from .hint, which always marks something unfinished. */
.note {
  font-size: .9rem;
  margin: 1rem 0 0 0 !important;
  padding-left: .9rem;
  border-left: 2px solid var(--accent-mid);
}

/* ── Filter ──────────────────────────────────────────────
   21 repositories is past the point where scanning beats searching
   (Rulebook §3.2 choice overload, §3.17 Miller's Law). */
.filter-row {
  display: flex;
  align-items: center;
  gap: .6rem;
  margin-bottom: .85rem;
}

.filter-label {
  font-family: var(--sans);
  font-size: .72rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
}

/* The "/" shortcut, stated where the field is rather than left as folklore.
   Revealed by JS only on a device with a hover-capable pointer, so a phone is
   never told to press a key it does not have. */
.filter-key {
  font-family: var(--sans);
  font-size: .72rem;
  color: var(--muted);
  order: 3;
  /* after the input, so it never splits label from field */
}

.filter-key kbd {
  font-family: var(--sans);
  font-size: .72rem;
  padding: .05rem .3rem;
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  border-radius: 3px;
  background: var(--surface);
  color: var(--text);
}

.filter-input {
  flex: 1 1 auto;
  min-width: 0;
  max-width: 26rem;
  min-height: 40px;
  padding: 0 .7rem;
  font-family: var(--sans);
  font-size: .82rem;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 0;
}

.filter-input::placeholder {
  color: var(--muted);
  opacity: 1;
}

.filter-input:hover {
  border-color: var(--accent-mid);
}

.filter-input:focus {
  border-color: var(--accent);
  outline: 2px solid rgba(var(--accent-rgb), .9);
  outline-offset: 1px;
}

.filter-input:disabled {
  background: var(--surface-2);
  cursor: not-allowed;
}

/* Loading / count / error line for the Projects table. */
.status {
  font-family: var(--sans);
  font-size: .75rem;
  color: var(--muted);
  margin: 0 0 .75rem 0 !important;
}

.status[data-state="error"] {
  color: var(--text);
  background: var(--accent-soft);
  border-left: 3px solid var(--accent);
  padding: .6rem .75rem;
  max-width: 65ch;
}

/* ── Buttons ─────────────────────────────────────────────── */
.button {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 1.1rem;
  background: var(--fg);
  color: var(--bg);
  font-family: var(--sans);
  font-size: .8rem;
  letter-spacing: .04em;
  text-decoration: none;
  border: 2px solid var(--fg);
  transition: background .12s ease, color .12s ease;
}

.button:hover,
.button:focus-visible {
  background: transparent;
  color: var(--fg);
  border-color: var(--accent);
}

/* Secondary action next to the primary one. Outlined rather than filled so the
   two buttons do not compete: the PDF is the main path, email is the fallback. */
.button-quiet {
  background: transparent;
  color: var(--fg);
  border-color: var(--border);
}

.button-quiet:hover,
.button-quiet:focus-visible {
  background: transparent;
  color: var(--fg);
  border-color: var(--accent);
}

.resume-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: .75rem 1.25rem;
}

/* ── Tables — the McMaster half ───────────────────────────
   Hidden overflow is hidden data (Rulebook §2.4): the wrapper scrolls
   rather than clipping, so a 5-column table stays reachable at 375px. */
.table-wrap {
  overflow-x: auto;
  border: 1px solid var(--border);
  background: var(--surface);
}

.grid-table {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--sans);
  font-size: .82rem;
  background: var(--surface);
  /* Monospace used to line the star counts and dates up by accident. With a
     proportional face the digits are different widths, so tabular figures are
     asked for explicitly — otherwise a column of numbers reads ragged. */
  font-variant-numeric: tabular-nums;
}

.grid-table th,
.grid-table td {
  border-bottom: 1px solid var(--border);
  border-right: 1px solid var(--border);
  padding: .55rem .7rem;
  text-align: left;
  vertical-align: top;
  font-weight: 400;
}

.grid-table th:last-child,
.grid-table td:last-child {
  border-right: none;
}

.grid-table tbody tr:last-child>* {
  border-bottom: none;
}

.grid-table thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--fg);
  color: var(--bg);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .06em;
  font-size: .72rem;
  white-space: nowrap;
  padding: 0;
  /* the button owns the padding */
  border-bottom: 2px solid var(--accent);
  /* accent as structure, not fill */
}

/* Non-sortable headers keep their own padding. */
.grid-table thead th:not(:has(.sort-btn)) {
  padding: .55rem .7rem;
}

/* Sort controls read as headers, behave as buttons. */
.sort-btn {
  display: flex;
  align-items: center;
  gap: .4rem;
  width: 100%;
  min-height: 40px;
  /* tap-target floor */
  padding: .55rem .7rem;
  background: none;
  border: 0;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  text-align: left;
  cursor: pointer;
}

.sort-btn:hover {
  color: var(--accent-mid);
}

/* The arrow is drawn from the header's aria-sort, so the visual state and the
   announced state cannot drift apart. */
.sort-btn::after {
  content: "\2195\FE0E";
  opacity: .35;
  font-size: .9em;
}

th[aria-sort="ascending"] .sort-btn::after {
  content: "\2191\FE0E";
  opacity: 1;
  color: var(--accent-mid);
}

th[aria-sort="descending"] .sort-btn::after {
  content: "\2193\FE0E";
  opacity: 1;
  color: var(--accent-mid);
}

.grid-table tbody tr:nth-child(even)>* {
  background: var(--surface-2);
}

.grid-table tbody tr:hover>* {
  background: var(--accent-soft);
}

/* Row headers are labels, not data. */
.grid-table tbody th[scope="row"] {
  color: var(--muted);
  white-space: nowrap;
}

.grid-table tbody tr:hover th[scope="row"] {
  color: var(--accent-text);
}

/* Projects proportions — description gets the room, the date stays tight.
   Four columns now: Name, Description, Language, Updated. The Stars column is
   gone; every repository had none, so it was a column of zeros pretending to
   be data and it cost sideways scroll at 375px to say nothing. */
#projects-table td:nth-child(2) {
  min-width: 20ch;
}

#projects-table td:nth-child(4) {
  white-space: nowrap;
  text-align: right;
}

#projects-table td:nth-child(1) {
  white-space: nowrap;
}

/* Skeleton rows. Shape-matched placeholders say what is coming, which a
   spinner cannot (Rulebook §1.1, §3.6). Not a progress bar: we do not know
   the percentage, so we do not draw one. */
.skeleton-row td:nth-child(1) .skeleton-bar {
  width: 60%;
}

.skeleton-row td:nth-child(2) .skeleton-bar {
  width: 90%;
}

.skeleton-row td:nth-child(3) .skeleton-bar {
  width: 50%;
}

.skeleton-row td:nth-child(4) .skeleton-bar {
  width: 70%;
}

.skeleton-bar {
  display: block;
  height: .7em;
  border-radius: 2px;
  background: linear-gradient(90deg,
      var(--surface-2) 25%, var(--border) 50%, var(--surface-2) 75%);
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.6s linear infinite;
}

@keyframes skeleton-shimmer {
  0% {
    background-position: 200% 0;
  }

  100% {
    background-position: -200% 0;
  }
}

.retry-btn {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  margin-top: .6rem;
  padding: 0 .9rem;
  background: transparent;
  border: 1px solid var(--fg);
  color: var(--fg);
  font-family: var(--sans);
  font-size: .75rem;
  cursor: pointer;
}

.retry-btn:hover {
  border-color: var(--accent);
  color: var(--accent-text);
}

/* Copy the email address. A mailto: on a machine with no mail client does
   nothing at all and says nothing about why — this is the way out (§1.3).
   Built in JS, so a browser without the Clipboard API gets no button rather
   than one that fails quietly. The label changes to "Copied" in words, not by
   colour, and reverts, so the confirmation cannot be missed or misread. */
.copy-btn {
  display: inline-flex;
  align-items: center;
  /* The 40px tap target is a HIT AREA, not a box — see ::after below. Sizing
     the box itself to 40px pushed the masthead's Contact line to 40px against
     24px for every other value, and the Links table's Email row to 58px
     against 39px. One row half again as tall as its neighbours reads as a
     mistake, because in every other table here it would be one. */
  min-height: 0;
  position: relative;
  margin-left: .5rem;
  padding: .1rem .55rem;
  line-height: 1.5;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--muted);
  font-family: var(--sans);
  font-size: .7rem;
  letter-spacing: .06em;
  text-transform: uppercase;
  cursor: pointer;
  vertical-align: middle;
}

/* The tap target, spent as an invisible overlay instead of as line height.
   §3.3 asks that nothing interactive be smaller than 40px to hit; it does not
   ask that every control be 40px tall. The overlay is centred on the chip and
   only ever spans the chip's own width, so it cannot swallow a click meant for
   the address beside it. */
.copy-btn::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 40px;
  transform: translateY(-50%);
}

.copy-btn:hover {
  border-color: var(--accent);
  color: var(--accent-text);
}

/* A scrollbar shows on desktop, but a touch device shows nothing until the user
   already guessed. Say it in words, only where it applies.

   **Two uses.** The ASCII diagram, which cannot reflow, and #compare-table, the
   one table that still scrolls sideways below 640px because a comparison matrix
   cannot stack into cards (tables.md §1a). The Skills and Certificates hints were
   removed when those tables stopped scrolling — a hint describing a gesture the
   page no longer asks for is worse than none. Never put this back on a table
   without checking that one still scrolls; never remove it from one that does.
   Hidden overflow is hidden data (UI-UX_Rulebook §2.4, §4). */
.scroll-hint {
  display: none;
}

/* ── Chips: topics, years, view modes ─────────────────────
   One control, three uses. The pressed state is drawn from aria-pressed, so
   the announced state and the visible one are the same fact — the contract the
   sort arrows already keep with aria-sort. Never colour alone: the active chip
   inverts its fill AND gains weight. */
.topic-chips,
.year-bar,
.view-switch {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .4rem;
  margin: 0 0 .85rem 0;
}

.topic-chips[hidden] {
  display: none;
}

.chip {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  /* tap-target floor (§3.3) */
  padding: 0 .7rem;
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--muted);
  font-family: var(--sans);
  font-size: .72rem;
  letter-spacing: .04em;
  cursor: pointer;
}

.chip:hover {
  border-color: var(--accent);
  color: var(--accent-text);
}

.chip[aria-pressed="true"] {
  background: var(--fg);
  border-color: var(--fg);
  color: var(--bg);
  font-weight: 700;
}

.view-switch {
  margin: 0;
}

.view-label {
  font-family: var(--sans);
  font-size: .72rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
}

/* ── Repository statistics ────────────────────────────────
   The bars are block characters in the cell, not CSS widths: a percentage
   width can only arrive as an inline style attribute, which rule 1.8 forbids
   and the CSP would not stop, so it would fail as a layout bug rather than as
   an error. Text bars also copy, print and read at any zoom. */
.repo-stats[hidden] {
  display: none;
}

.stat-row {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem 2.25rem;
  margin: 0 0 .85rem 0;
}

.stat-row div {
  display: flex;
  flex-direction: column;
  gap: .1rem;
}

.stat-row dt {
  font-family: var(--sans);
  font-size: .68rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
}

.stat-row dd {
  margin: 0;
  font-size: .92rem;
  font-weight: 700;
  color: var(--fg);
  font-variant-numeric: tabular-nums;
}

/* The one place a monospace face is correct: the bar only lines up if every
   cell is the same width. It is decoration for the number beside it, not text
   to read, so it is hidden from assistive tech in the markup. */
.bar {
  font-family: "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace;
  font-size: .8em;
  letter-spacing: -.05em;
  color: var(--accent-text);
  white-space: nowrap;
}

.lang-table td:nth-child(3) {
  text-align: right;
  white-space: nowrap;
}

/* ── Selected projects ────────────────────────────────────
   A list, not cards. Curation lives on GitHub via the "featured" topic, so
   with nothing tagged the whole band is hidden rather than standing empty. */
.featured-band[hidden] {
  display: none;
}

.featured-list {
  list-style: none;
  margin: 0 0 1.25rem 0;
  padding: 0;
  border: 1px solid var(--border);
  background: var(--surface);
}

.featured-item {
  display: grid;
  grid-template-columns: minmax(8rem, 14rem) minmax(5rem, 8rem) 1fr;
  gap: .25rem 1rem;
  padding: .6rem .7rem;
  border-bottom: 1px solid var(--border);
  font-size: .82rem;
}

.featured-item:last-child {
  border-bottom: none;
}

.featured-item:hover {
  background: var(--accent-soft);
}

.featured-name {
  font-weight: 700;
}

.featured-lang {
  color: var(--muted);
}

.featured-desc {
  color: var(--text);
}

/* ── Row-level detail control ─────────────────────────────
   Sits beside the repository link rather than replacing it: the link stays a
   link, so middle-click, "open in new tab" and "copy link address" all keep
   working, and the second action is named rather than hidden in a row click. */
.detail-btn {
  display: inline-flex;
  align-items: center;
  position: relative;
  margin-left: .4rem;
  padding: .05rem .4rem;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--muted);
  font-family: var(--sans);
  font-size: .66rem;
  letter-spacing: .04em;
  text-transform: uppercase;
  cursor: pointer;
  vertical-align: middle;
}

/* Tap target spent as an overlay, not as line height — the same trick
   .copy-btn uses, and for the same reason: a 40px-tall box here would make
   every Projects row half again as tall as the ones around it. */
.detail-btn::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 40px;
  transform: translateY(-50%);
}

.detail-btn:hover {
  border-color: var(--accent);
  color: var(--accent-text);
}

.compare-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .5rem;
  margin: 0 0 .85rem 0 !important;
  font-family: var(--sans);
  font-size: .75rem;
  color: var(--muted);
}

/* `.retry-btn`'s `margin-top` is for the standalone case — the Retry that appears
   under a failed status line and needs separating from it. Anywhere the button
   sits *inline in a row*, that margin is a misalignment: with `align-items:
   center` the flex box centres the **margin** box, so a 9.6px top margin drops
   the border box by half of it. Measured: Reset sat **4.8px** below three selects
   whose tops were identical.

   Zeroed per container rather than removed from the base rule, because the
   standalone case is the common one. See also the 640px block, where the Reset
   becomes its own grid row and the separation is wanted again. */
.compare-bar .retry-btn,
.filter-row .retry-btn {
  margin-top: 0;
}

/* ── Multi-select filter row ──────────────────────────────
   Selects, not a text box: the values are a closed set the table already
   knows, and free text over a closed set invites a query that can only fail. */
.filter-row-multi {
  flex-wrap: wrap;
  gap: .4rem .6rem;
}

.filter-select {
  min-height: 40px;
  padding: 0 .5rem;
  font-family: var(--sans);
  font-size: .78rem;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 0;
}

.filter-select:hover {
  border-color: var(--accent-mid);
}

.filter-select:focus {
  border-color: var(--accent);
  outline: 2px solid rgba(var(--accent-rgb), .9);
  outline-offset: 1px;
}

/* A row removed by a filter, not by a view mode. Kept as its own class so the
   two can never fight over the same attribute. */
.filtered-out {
  display: none;
}

/* A sub-heading and the table under it, once a filter has taken every row in
   it. Set by syncEmptyBlocks() in script.js, because emptiness cannot be asked
   in CSS. Deliberately NOT .resume-empty: that one is print-only and is
   stripped from every element on afterprint, which would silently undo this. */
.block-empty {
  display: none;
}

/* ── View modes ───────────────────────────────────────────
   Full is the default and hides nothing. A mode hides rows and blocks that
   carry data-hide-in, and nothing else — the toggle is always visible, so no
   content is ever gated behind a click the reader does not know they can undo
   (§3.8). */
body.view-recruiter [data-hide-in~="recruiter"],
body.view-developer [data-hide-in~="developer"] {
  display: none;
}

/* ── Density ──────────────────────────────────────────────
   A reading preference, so it is remembered — unlike fold state, which §3.8
   deliberately forgets. */
body.density-compact .grid-table th,
body.density-compact .grid-table td {
  padding: .3rem .55rem;
}

body.density-compact .grid-table {
  font-size: .78rem;
}

body.density-compact .sort-btn {
  padding: .3rem .55rem;
}

/* ── Copy a link to a section ─────────────────────────────
   **Out of the <summary>, on purpose.** It used to be appended to the
   `.section-title`, which is the summary's only child, and interactive content
   inside a <summary> is disallowed: the browser owns that element's activation,
   so a button nested in it is not consistently reachable by keyboard or by
   assistive tech. Chrome reports it, and it was reported for all eight sections.

   So the button is a child of the `.section` instead — first child, so the tab
   order still meets it before the heading it belongs to — and positioned out of
   flow into the heading band. `right` clears the fold chevron, which sits at the
   summary's right edge. The old click handler no longer needs to stop the event
   from reaching the fold, because the fold is no longer an ancestor.

   Being out of flow is also what buys the 40px tap target honestly: sized to 40
   inside the <summary> it would have set the height of every section heading,
   which is why it used to spend an invisible ::before overlay instead. */
.anchor-btn {
  position: absolute;
  top: 2.75rem;
  /* .section's padding-top: aligns with the heading's 40px band */
  right: 1.5rem;
  /* clear of the chevron */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* 40 in both directions, which the ::before overlay never managed: it was
     `left: 0; right: 0`, so it inherited the box's ~19px width and only ever
     bought the height. Out of flow, the box can simply be the tap target. */
  min-width: 40px;
  min-height: 40px;
  padding: 0 .35rem;
  background: transparent;
  border: 1px solid transparent;
  color: var(--muted);
  font-family: var(--sans);
  font-size: .85rem;
  cursor: pointer;
}

.section:hover .anchor-btn {
  border-color: var(--border);
}

.anchor-btn:hover {
  color: var(--accent-text);
  border-color: var(--accent);
}

/* Confirmation in a word, not in a colour — the same contract .copy-btn keeps. */
.anchor-btn.is-done::after {
  content: " copied";
  font-size: .7rem;
  letter-spacing: .04em;
  text-transform: none;
  color: var(--accent-text);
}

/* The other half of that contract, which was missing: a denied clipboard used
   to announce the failure to a screen reader and show a sighted reader nothing
   at all, so a broken copy and a working one looked identical. Same word-not-
   colour rule, and --muted rather than a red, because the reader has lost
   nothing — the address is still on screen to select by hand. */
.anchor-btn.is-failed::after {
  content: " press Ctrl+C";
  font-size: .7rem;
  letter-spacing: .04em;
  text-transform: none;
  color: var(--muted);
}

/* ── Detail list ──────────────────────────────────────────
   For what a one-sentence "Highlights" cell cannot hold. */
.detail-list {
  display: grid;
  grid-template-columns: minmax(6rem, 9rem) 1fr;
  gap: .35rem 1rem;
  margin: .85rem 0 0 0;
  padding: .85rem 0 0 0;
  border-top: 1px solid var(--border);
  font-size: .85rem;
}

.detail-list div {
  display: contents;
}

.detail-list dt {
  font-family: var(--sans);
  font-size: .68rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
  padding-top: .15rem;
}

.detail-list dd {
  margin: 0;
}

/* ── Plain-text diagram ───────────────────────────────────
   Content, not an image: it copies as text and needs no binary in assets/.
   The wrapper is what keeps it from pushing the page sideways at 375px, which
   §3.6 forbids outright — the diagram scrolls inside its own box instead. */
.ascii-wrap {
  overflow-x: auto;
  border: 1px solid var(--border);
  background: var(--surface);
  padding: .85rem;
}

.ascii-art {
  margin: 0;
  font-family: "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace;
  font-size: .72rem;
  line-height: 1.45;
  color: var(--text);
  white-space: pre;
}

/* ── Search dialog ────────────────────────────────────────
   Wider and taller than the export dialog because it holds a list that grows;
   the results scroll inside it rather than growing the modal past the screen.

   Centred like every other dialog, and given a **fixed** height rather than a
   maximum to make that safe. Centred plus auto height means the box grows
   symmetrically as results arrive, so the input slides upward out from under the
   finger already typing into it. A stable height keeps the input still and lets
   the list scroll inside — which is what `.search-results` was already for.
   This replaced a 6vh top offset, which held the input still by not centring. */
.search-dialog {
  width: min(46rem, 94vw);
  height: min(82vh, 34rem);
  margin: auto;
  padding: 0;
  border: 2px solid var(--rule);
  background: var(--bg);
  color: var(--text);
}

.search-dialog::backdrop {
  background: rgba(17, 16, 16, .45);
}

.search-form {
  display: flex;
  flex-direction: column;
  height: 100%;
  box-sizing: border-box;
  padding: 1.1rem;
}

.search-bar {
  display: flex;
  gap: .6rem;
  align-items: center;
}

.search-input {
  flex: 1 1 auto;
  max-width: none;
}

.search-close {
  flex: 0 0 auto;
}

.search-results {
  list-style: none;
  margin: .4rem 0 0 0;
  padding: 0;
  overflow-y: auto;
  border-top: 1px solid var(--border);
}

.search-hit {
  border-bottom: 1px solid var(--border);
}

.search-hit a {
  display: block;
  padding: .55rem .35rem;
  text-decoration: none;
}

.search-hit a:hover,
.search-hit a:focus-visible {
  background: var(--accent-soft);
}

.search-where {
  display: block;
  font-family: var(--sans);
  font-size: .66rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
}

.search-what {
  display: block;
  font-size: .82rem;
  color: var(--text);
}

.search-hit a:hover .search-what {
  color: var(--accent-text);
}

/* ── Command palette ──────────────────────────────────────
   Commands share the results list with content hits rather than living in a
   second panel: one list means one set of arrow keys and one Enter. They are
   told apart by a group heading and a leading marker, not by position, because
   position changes with the query. */
.search-group {
  font-family: var(--sans);
  font-size: .64rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
  padding: .5rem .35rem .25rem;
  border-bottom: 1px solid var(--border);
}

.search-hit button {
  display: block;
  width: 100%;
  text-align: left;
  padding: .55rem .35rem;
  font: inherit;
  color: var(--text);
  background: none;
  border: 0;
  cursor: pointer;
}

/* A content hit stacks: source line, then matched text. A command has no
   source line, so its label sits beside the marker instead of under it —
   `.search-what` is shared, and its `display: block` would wrap a one-line
   command onto two. */
.search-hit button .search-what {
  display: inline;
}

.search-hit button:hover,
.search-hit button:focus-visible {
  background: var(--accent-soft);
  color: var(--accent-text);
}

/* The marker is a character, not an icon font and not an image — same reason
   the work diagram is drawn in text. */
.search-cmd-mark {
  color: var(--muted);
  margin-right: .5rem;
}

.search-hit button:hover .search-cmd-mark,
.search-hit button:focus-visible .search-cmd-mark {
  color: var(--accent-text);
}

/* ── The searched-for text, once you land on it ───────────
   CSS Custom Highlight API. It paints a Range without wrapping it in a node,
   which is the whole reason it is used here: a <mark> inserted into a cell
   would be read back out by readableText(), and readableText() feeds the
   search index, the JSON view and the résumé line budget. That bug family has
   already cost three rounds. Nothing to insert means nothing to unwrap and
   nothing to leak. */
::highlight(search-hit-mark) {
  background-color: var(--accent-soft);
  color: var(--accent-text);
}

/* Where the API is missing the jump still lands; the row is flashed instead so
   the reader is not left scanning for what matched. Held, not pulsed — one
   fade is information, a repeating pulse is decoration. */
.hit-flash {
  animation: hit-flash 2.4s ease-out 1 forwards;
}

@keyframes hit-flash {
  0%, 55% { background-color: var(--accent-soft); }
  100%    { background-color: transparent; }
}

@media (prefers-reduced-motion: reduce) {
  /* Same duration, same information, no animation: the highlight is simply
     there and then gone. */
  .hit-flash {
    animation: none;
    background-color: var(--accent-soft);
  }
}

/* ── Focus mode ───────────────────────────────────────────
   The third and last thing on this page that hides content, alongside
   .filtered-out (filters) and data-hide-in (view modes). It gets its own body
   class rather than borrowing either, so no two mechanisms ever contend for
   one attribute — see state-and-data.md §2. Nothing here announces focus mode
   exists; the only time it is visible is while it is on. */
body.focus-mode .masthead,
body.focus-mode .fold-controls,
body.focus-mode .section:not(.focus-target) {
  display: none;
}

.focus-exit {
  font-family: var(--sans);
  font-size: .7rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--fg);
  background: var(--accent-soft);
  border: 1px solid var(--accent);
  cursor: pointer;
  display: flex;
  align-items: center;
  min-height: 40px;
  padding: 0 .7rem;
  white-space: nowrap;
}

.focus-exit:hover,
.focus-exit:focus-visible {
  background: var(--accent);
  color: var(--bg);
}

/* ── What a jump just changed ─────────────────────────────
   A search result can clear a filter or switch a view mode to reach its row.
   That is a visible change to the page made by something the reader did not
   quite ask for, so it is stated in words on the page rather than only to a
   screen reader. Hidden — genuinely, with the hidden attribute — whenever it
   has nothing to say, so a reader who never searches never meets it.

   Fixed to the bottom rather than placed in the flow: the jump has just
   scrolled the page somewhere, so a note sitting at the top of the document is
   a message delivered to where the reader no longer is. Bottom rather than
   under the header, so it never covers the nav or the section it just
   revealed. */
.jump-note {
  position: fixed;
  left: 1rem;
  bottom: 1rem;
  z-index: 30;
  max-width: min(30rem, calc(100vw - 2rem));
  margin: 0;
  padding: .55rem .8rem;
  font-family: var(--sans);
  font-size: .76rem;
  color: var(--fg);
  background: var(--surface-2);
  border: 1px solid var(--accent);
  border-left-width: 3px;
  box-shadow: 0 2px 8px rgba(17, 16, 16, .12);
}

/* ── Keyboard glyphs ──────────────────────────────────────
   Shared with the filter hint's own kbd, which was already styled above. */
.grid-table kbd {
  font-family: var(--sans);
  font-size: .72rem;
  padding: .05rem .3rem;
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  border-radius: 3px;
  background: var(--surface);
  color: var(--text);
}

/* ── JSON view ────────────────────────────────────────────
   tabindex on the <pre> so a keyboard can scroll it; without that the content
   is reachable by screen reader but not by arrow key. */
.json-out {
  max-height: 50vh;
  overflow: auto;
  margin: 0 0 1rem 0;
  padding: .75rem;
  border: 1px solid var(--border);
  background: var(--surface);
  font-family: "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace;
  font-size: .72rem;
  line-height: 1.5;
  color: var(--text);
  white-space: pre;
}

/* ── Footer ──────────────────────────────────────────────── */
.site-footer {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 1.25rem;
  border-top: 2px solid var(--rule);
  font-family: var(--sans);
  font-size: .72rem;
  color: var(--muted);
}

.footer-top {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: .4rem 1.5rem;
}

.site-footer a {
  color: var(--muted);
  text-decoration: underline;
  text-decoration-color: var(--accent-mid);
}

.site-footer a:hover {
  color: var(--accent-text);
}

/* "Back to top" is a navigation control, not a word in a sentence, so the 40px
   floor applies to it — it measured 17px. Padding rather than an overlay,
   because there is nothing beside it to collide with. */
.footer-top a {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding-inline: .15rem;
}

/* ── Colophon ─────────────────────────────────────────────
   Folded into the footer rather than given a section of its own. The nav
   already wraps to two rows at 375px; a ninth destination would take it to
   three and break the anchor offset every heading depends on (§3.6).

   Closed by default, and that is not a violation of §3.8 — the invariant is
   about the record itself. This is a note about the site, below the footer
   rule, reachable in one click and named plainly. */
.colophon {
  margin-top: 1rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
}

.colophon-summary {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  cursor: pointer;
  list-style: none;
  font-size: .72rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
}

.colophon-summary::-webkit-details-marker {
  display: none;
}

/* Chevron drawn from borders, matching the section folds: down is collapsed,
   up is expanded, per accordion convention. */
.colophon-summary::before {
  content: "";
  width: .42rem;
  height: .42rem;
  margin-right: .5rem;
  border-right: 2px solid var(--accent);
  border-bottom: 2px solid var(--accent);
  transform: rotate(45deg);
  margin-top: -.2rem;
  transition: transform .15s ease, margin-top .15s ease;
}

.colophon[open]>.colophon-summary::before {
  transform: rotate(-135deg);
  margin-top: .15rem;
}

.colophon-summary:hover {
  color: var(--accent-text);
}

.colophon-body {
  padding-top: .5rem;
}

.colophon-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
  gap: 1rem 1.5rem;
}

.colophon-body .subhead {
  margin-top: 1.25rem;
}

.colophon-grid .subhead:first-child {
  margin-top: 0;
}

.colophon-note {
  margin: 0 0 .75rem 0;
  font-size: .75rem;
  line-height: 1.6;
  color: var(--muted);
}

.colophon-note code {
  font-family: "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace;
  font-size: .92em;
  color: var(--text);
}

.colophon-body code {
  font-family: "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace;
  font-size: .92em;
}

.colophon-actions {
  display: flex;
  flex-wrap: wrap;
  gap: .6rem;
  margin: 1rem 0 0 0;
}

.colophon-actions .button {
  min-height: 40px;
  font-size: .72rem;
}

/* ── Mobile (tested at 375px) ────────────────────────────── */
@media (max-width: 640px) {

  html,
  body {
    font-size: 15px;
  }

  /* Two rows, not three. A column of wordmark / nav / tools costs a whole 40px
     band of a 812px-tall screen for a control that fits beside the wordmark
     with room to spare — and the sticky header's height is subtracted from
     every section's scroll-margin-top, so a third row is paid for on every
     jump as well. Ordering the tools onto row one and giving the nav a full
     basis holds the header at the 124px it was before it had any. */
  .site-header {
    flex-flow: row wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0;
    padding: 0 1rem;
  }

  .wordmark {
    min-height: 40px;
  }

  .header-tools {
    order: 1;
  }

  .site-nav {
    order: 2;
    flex-basis: 100%;
    gap: 0 .9rem;
    font-size: .78rem;
    padding-bottom: .15rem;
  }

  .site-nav a {
    min-height: 40px;
  }

  main {
    padding: 0 1rem;
  }

  .masthead {
    padding: 2.25rem 0 1.75rem;
  }

  .masthead-meta {
    gap: .9rem;
    margin-top: 1.5rem;
  }

  /* Eight nav items wrap to two rows here, making the sticky header ~124px.
     The offset must clear it or anchor links land underneath the nav. */
  /* 11.5rem = 172px against a 124px header. The extra headroom is deliberate:
     a ninth nav item would wrap the nav to three rows (~160px) and anchors
     would land under it. Better to carry the slack than to break silently. */
  .section {
    padding: 2rem 0;
    scroll-margin-top: 11.5rem;
  }

  /* Tracks the section's padding-top above: the button is positioned into the
     heading band, so the two numbers have to move together. */
  .anchor-btn {
    top: 2rem;
  }

  /* .74rem on a 15px root read as 11.1px — legible on a desktop monitor,
     not on a phone held at arm's length. .88rem = 13.2px, which sits close
     enough to the 15px prose to stop the tables feeling like fine print.
     The padding is tightened to buy back the width the larger type costs,
     so the 5-column Projects table does not scroll any further than before. */
  .grid-table {
    font-size: .88rem;
  }

  .grid-table thead th {
    font-size: .8rem;
  }

  .grid-table th,
  .grid-table td {
    padding: .5rem .6rem;
  }

  .grid-table thead th:not(:has(.sort-btn)) {
    padding: .5rem .6rem;
  }

  .sort-btn {
    padding: .5rem .6rem;
  }

  .scroll-hint {
    display: block;
  }

  /* syncScrollableTables() hides a hint whose wrap does not actually overflow.
     A display rule beats the hidden attribute, so without this the hint renders
     while claiming to be hidden (layout.md §3a). */
  .scroll-hint[hidden] {
    display: none;
  }

  /* 16px floor: iOS zooms the page on focus below this and does not undo it. */
  .filter-input {
    font-size: 16px;
  }

  .filter-row {
    flex-wrap: wrap;
  }

  /* ── Condensed header ──────────────────────────────────
     Eight nav items on two rows cost 124px of a 667px phone screen, held
     there by position:sticky for the whole read. Once the masthead is off
     screen the header drops to one row (~48px) and hands 76px back.

     Nothing is removed — the row scrolls sideways instead, and the mask
     fade on the right edge is what says so. A hard clipped edge would read
     as the end of the list (Gestalt §2.4, closure), which is the one thing
     it must not do. JS keeps the current section scrolled into view. */
  .nav-condensed .site-header {
    flex-direction: row;
    align-items: center;
    gap: 0 .75rem;
  }

  .nav-condensed .wordmark {
    flex: 0 0 auto;
    font-size: .8rem;
    min-height: 40px;
  }

  /* Condensed, everything is on one 40px row and the nav is the part that
     scrolls, so the tools go last and the readout goes away entirely. The
     readout is a convenience restating what the nav already marks with
     aria-current; the nav itself is the map, and taking 55px off a scrolling
     strip to repeat a fact it already shows is the wrong trade. */
  .nav-condensed .header-tools {
    order: 2;
    flex: 0 0 auto;
  }

  .nav-condensed .doc-position {
    display: none;
  }

  .nav-condensed .site-nav {
    order: 1;
    flex: 1 1 0;
    flex-basis: 0;
    min-width: 0;
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    /* Slack the width of the fade, so the last item can scroll clear of it.
       Without this Résumé is permanently half-faded whenever it is the
       marked section — the scroll range simply ends underneath the mask. */
    padding: 0 2.25rem 0 0;
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.75rem), transparent);
    mask-image: linear-gradient(to right, #000 calc(100% - 1.75rem), transparent);
  }

  .nav-condensed .site-nav::-webkit-scrollbar {
    display: none;
  }

  .nav-condensed .site-nav a {
    flex: 0 0 auto;
    white-space: nowrap;
    min-height: 40px;
  }

  /* The offset is sized for the tall header. Against the condensed one it
     would strand every heading 76px down the page, so it shrinks with it. */
  .nav-condensed .section {
    scroll-margin-top: 4.5rem;
  }

  .site-footer {
    padding: 1rem;
  }

  /* Selected projects: three columns will not fit 375px, so the row becomes
     three stacked lines rather than a grid squeezed to nothing. */
  .featured-item {
    grid-template-columns: 1fr;
    gap: .15rem;
  }

  .featured-lang {
    font-size: .72rem;
  }

  /* The stat row is four labelled values; at 375px they read as two columns
     rather than one long ladder. */
  .stat-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: .6rem 1rem;
  }

  /* Two labels and two selects per line rather than six items in a queue. */
  .filter-row-multi {
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: center;
  }

  /* On its own row now, not beside the selects, so the separation the base rule
     provides is wanted back. */
  .filter-row-multi .retry-btn {
    grid-column: 1 / -1;
    justify-self: start;
    margin-top: .6rem;
  }

  .detail-list {
    grid-template-columns: 1fr;
    gap: .1rem;
  }

  .detail-list div {
    display: block;
    margin-bottom: .6rem;
  }

  .search-dialog {
    width: 96vw;
    height: min(88vh, 34rem);
  }

  .search-form {
    padding: .85rem;
  }

  .colophon-grid {
    grid-template-columns: 1fr;
  }
}

/* ── Narrow screens: rows stack, nothing scrolls sideways ──
   A four- or five-column table cannot fit 375px and stay a table, and the
   wrapper's sideways scroll was the price paid for keeping every column. That
   price is now refused: below 640px each row becomes a stacked card, so the
   whole record is reachable by scrolling one direction only.

   `screen and`, deliberately. Print has its own linearisation in
   `body.print-resume`, driven by what the reader ticked rather than by the
   width of a page, and these rules must not reach it.

   The cell labels come from each table's own `<thead>`, set as `data-label` by
   `labelCells()` in script.js. Nothing is hand-tagged, and an attribute is
   invisible to `readableText()`, so the search index, the JSON view and the
   résumé line budget are untouched by any of this. */
@media screen and (max-width: 640px) {

  /* Nothing overflows now, so `syncScrollableTables()` strips the tabindex and
     role=region by itself — 9 keyboard stops that no longer lead anywhere. The
     wrapper's own frame goes too: the card border is the frame now, and two
     nested boxes around one row reads as a mistake. */
  .table-wrap {
    overflow-x: visible;
    border: 0;
    background: transparent;
  }

  .grid-table {
    display: block;
    background: transparent;
  }

  /* The header row survives only where it is a control. Where it carries sort
     buttons it stays, as a wrapped bar above the cards — a sort reachable only
     by a column header is a sort a phone does not have, which is the trap the
     header Search control was added to close. Where it carries plain column
     names it goes: every cell now states its own column, so leaving it would
     print the same words twice. */
  .grid-table thead {
    display: none;
  }

  .grid-table thead:has(.sort-btn) {
    display: block;
    margin-bottom: .7rem;
    border: 1px solid var(--border);
    background: var(--surface);
  }

  .grid-table thead::before {
    content: "Sort by";
    display: block;
    padding: .5rem .6rem 0;
    font-family: var(--sans);
    font-size: .68rem;
    letter-spacing: .1em;
    text-transform: uppercase;
    color: var(--muted);
  }

  .grid-table thead tr {
    display: flex;
    flex-wrap: wrap;
    gap: .4rem;
    padding: .45rem .6rem .6rem;
  }

  /* `position: static` matters: the header cells are sticky against the
     wrapper's scroll on wider screens, and a sticky box inside a bar that no
     longer scrolls is a box that can drift out of it. */
  .grid-table thead th {
    display: block;
    position: static;
    flex: 0 0 auto;
    border: 0;
    padding: 0;
    background: transparent;
  }

  /* A column with no sort button is a dead label inside a control bar. */
  .grid-table thead th:not(:has(.sort-btn)) {
    display: none;
  }

  /* ── The sort buttons have to look like buttons ─────────
     `.sort-btn` is `color: inherit`, and a table header inherits the muted grey
     that reads as a *label* on a column it is sitting on top of. Lifted out into
     a bar with no column under it, three grey words on cream read as decoration:
     it was not clear there was anything to press. So each becomes a bordered chip
     in full `--fg`, and the full-width flex of the desktop header goes with it.

     **Orange is the pressed state, and it is drawn from `aria-sort`** — the same
     rule the arrow follows, so what is announced and what is filled cannot
     drift. `--accent-soft` behind `--accent-text`, the pair already carrying the
     search highlight; never `--accent` itself, which is 2.64:1 on cream. */
  .grid-table thead .sort-btn {
    width: auto;
    gap: .35rem;
    padding: .45rem .65rem;
    border: 1px solid var(--border);
    background: var(--bg);
    color: var(--fg);
  }

  /* The "not sorted by this" arrow. .35 was set against a column header the eye
     had already found; on a chip it is the only thing saying the chip sorts. */
  .grid-table thead .sort-btn::after {
    opacity: .6;
  }

  .grid-table thead th[aria-sort="ascending"] .sort-btn,
  .grid-table thead th[aria-sort="descending"] .sort-btn {
    border-color: var(--accent);
    background: var(--accent-soft);
    color: var(--accent-text);
  }

  /* `--accent-mid` is a pale peach: legible as a tint behind text, not as the
     glyph itself once the chip around it is filled. */
  .grid-table thead th[aria-sort="ascending"] .sort-btn::after,
  .grid-table thead th[aria-sort="descending"] .sort-btn::after {
    color: var(--accent-text);
  }

  .grid-table tbody,
  .grid-table tbody tr,
  .grid-table tbody th,
  .grid-table tbody td {
    display: block;
  }

  .grid-table tbody tr {
    margin-bottom: .7rem;
    border: 1px solid var(--border);
    background: var(--surface);
  }

  .grid-table tbody tr:last-child {
    margin-bottom: 0;
  }

  /* Hairlines now separate the fields of one row rather than the columns of a
     grid, so the right-hand edge each cell carried has nothing left to divide.
     The screen rules that strip the outer edges are keyed to `:last-child`,
     which under a stacked row means the last *field* — they are restated here
     against the row instead. */
  .grid-table tbody th,
  .grid-table tbody td {
    border-right: 0;
    border-bottom: 1px solid var(--border);
    padding: .5rem .7rem;
  }

  .grid-table tbody tr> :last-child {
    border-bottom: 0;
  }

  /* The row header is the card's title. */
  .grid-table tbody th[scope="row"] {
    background: var(--bg);
    font-weight: 600;
  }

  .grid-table tbody [data-label]::before {
    content: attr(data-label);
    display: block;
    margin-bottom: .15rem;
    font-family: var(--sans);
    font-size: .68rem;
    letter-spacing: .1em;
    text-transform: uppercase;
    color: var(--muted);
  }

  /* Long descriptions and URLs are the two things that can still push a card
     past the screen once the columns are gone. */
  .grid-table tbody td {
    overflow-wrap: anywhere;
  }

  /* ── The one exception ──────────────────────────────────
     The comparison matrix is repositories against attributes, not a list of
     rows: stacked, each card would hold one attribute across every repository
     and the comparison — the entire point — would be gone. It stays a table and
     keeps scrolling inside its own dialog, which is what the dialog is for. The
     rule this section is written against is about the page a reader scrolls,
     not about a matrix they opened on purpose. */
  #compare-table,
  #compare-table thead,
  #compare-table tbody,
  #compare-table tr,
  #compare-table th,
  #compare-table td {
    display: revert;
  }

  #compare-table thead::before {
    content: none;
  }

  #compare-table thead th {
    position: sticky;
    top: 0;
  }

  #compare-table th,
  #compare-table td {
    border-right: 1px solid var(--border);
    padding: .5rem .6rem;
  }

  #compare-table th:last-child,
  #compare-table td:last-child {
    border-right: 0;
  }

  #compare-dialog .table-wrap {
    overflow-x: auto;
    border: 1px solid var(--border);
    background: var(--surface);
  }
}

/* ── Collapsible sections ─────────────────────────────────
   Native <details>, so keyboard operation, the expanded/collapsed state
   announcement, and find-in-page auto-expansion all come from the browser.
   Every section is open on load: a reader who never clicks still sees
   everything, and collapsing is an affordance rather than a gate. */
.section-summary {
  display: flex;
  align-items: center;
  gap: .6rem;
  min-height: 40px;
  /* tap-target floor */
  cursor: pointer;
  list-style: none;
  /* Firefox default marker */
}

.section-summary::-webkit-details-marker {
  display: none;
}

/* The marker is drawn from the open state, so what is shown and what is
   announced cannot drift apart — the same rule the sort arrows follow.

   A chevron rather than a +/− glyph: it is drawn from borders rather than set
   in type, so its weight and size are controlled rather than inherited from
   whatever the font happens to give a plus sign. Down = collapsed (content is
   below, press to bring it out), up = expanded (press to fold it away), which
   is the convention every accordion the reader has already used (Jakob's Law). */
.section-summary::after {
  content: "";
  margin-left: auto;
  flex: 0 0 auto;
  width: .62rem;
  height: .62rem;
  margin-top: -.28rem;
  /* optical centring of a rotated square */
  border-right: 2.5px solid var(--accent-text);
  border-bottom: 2.5px solid var(--accent-text);
  transform: rotate(45deg);
  /* chevron pointing down — collapsed */
  transition: transform .15s ease;
}

.section-fold[open]>.section-summary::after {
  margin-top: .22rem;
  transform: rotate(-135deg);
  /* chevron pointing up — expanded */
}

.section-summary:hover::after {
  border-right-color: var(--accent);
  border-bottom-color: var(--accent);
}

.section-summary:hover .section-title {
  color: var(--accent-text);
}

.section-summary:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), .9);
  outline-offset: 3px;
}

.section-summary .section-title {
  margin-bottom: 0;
}

.section-fold[open]>.section-summary .section-title {
  margin-bottom: 1.25rem;
}

/* One control for eight folds. Quiet by design: it is an accelerator for the
   reader who wants everything, not a competing call to action (Nielsen §1.7,
   §1.8). Added by script.js, so it never appears without the JS to run it. */
/* The bar now carries three controls — expand/collapse, density and the view
   switch — so it wraps rather than assuming one line. */
.fold-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .6rem 1.5rem;
  margin: 1.5rem 0 0 0;
  padding: 0;
}

.fold-toggle {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: 0 .1rem;
  background: none;
  border: 0;
  font-family: var(--sans);
  font-size: .72rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
  cursor: pointer;
  border-bottom: 1px solid var(--accent-mid);
}

.fold-toggle:hover {
  color: var(--accent-text);
  border-bottom-color: var(--accent);
}

.fold-toggle:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), .9);
  outline-offset: 3px;
}

/* ── Export dialog ────────────────────────────────────────
   Native <dialog>: the browser owns the focus trap, the inertness of the page
   behind it, and Escape-to-close. */
/* The first raster image on the page, so the first chance for one to escape its
   container. Without `max-width` a 512px code is wider than a 375px phone and
   takes the whole document into horizontal scroll — the one layout failure this
   file measures for on every round. Global, not scoped to the QR: the next image
   added should inherit the rule rather than rediscover it.

   `height: auto` alongside it because both images here carry width/height
   attributes — those reserve the right space before the file loads, and without
   `height: auto` the attribute wins over the scaled width and squashes the code. */
img {
  max-width: 100%;
  height: auto;
}

/* A QR needs a quiet margin and a light backing to scan. The rest of the page
   sits on cream; a code printed on cream still scans, but contrast is the whole
   mechanism, so this one gets paper white. Capped at 15rem so it never exceeds a
   phone's short side. */
.qr-image {
  display: block;
  width: 100%;
  max-width: 15rem;
  margin: 0 auto 0.9rem;
  padding: 0.6rem;
  background: #fff;
  border: 1px solid var(--rule);
}

.qr-address {
  margin: 0 0 0.4rem;
  font-family: var(--mono);
  text-align: center;
}

/* Print only. `display: none` here rather than the `hidden` attribute: hidden
   would have to be removed by JS to print, and the print path must not depend on
   JS having run. */
.masthead-qr {
  display: none;
}

.export-dialog {
  width: min(34rem, calc(100vw - 2rem));
  padding: 0;
  border: 2px solid var(--rule);
  background: var(--surface);
  color: var(--text);
}

.export-dialog::backdrop {
  background: rgba(17, 16, 16, .55);
}

/* The comparison and JSON dialogs hold a table and a code block, both of which
   need the room. They scroll inside themselves rather than growing the modal
   past the viewport. */
.export-dialog-wide {
  width: min(56rem, calc(100vw - 2rem));
  max-height: 86vh;
  overflow-y: auto;
}

.export-form {
  padding: 1.5rem;
}

.export-title {
  font-family: var(--sans);
  font-size: .9rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  margin: 0 0 .5rem 0;
}

.export-intro {
  font-size: .9rem;
  color: var(--muted);
  margin: 0 0 1.25rem 0;
}

.export-group {
  border: 0;
  border-top: 1px solid var(--border);
  padding: 1rem 0 0 0;
  margin: 0 0 1rem 0;
}

.export-group legend {
  font-family: var(--sans);
  font-size: .7rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
  padding: 0 .5rem 0 0;
}

/* The one-page estimate. Stated in words and not in colour alone — over
   budget reads as over budget with the sound off (Rulebook §4). */
.export-budget {
  margin: .7rem 0 0 0;
  padding: .45rem .6rem;
  background: var(--surface-2);
  border-left: 3px solid var(--border);
  font-size: .8rem;
  color: var(--muted);
}

.export-budget.is-over {
  background: var(--accent-soft);
  border-left-color: var(--accent);
  color: var(--accent-text);
}

.export-checks {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: .15rem 1rem;
}

.export-check {
  display: flex;
  align-items: center;
  gap: .55rem;
  min-height: 40px;
  font-size: .9rem;
  cursor: pointer;
}

.export-check input {
  accent-color: var(--accent);
  flex: 0 0 auto;
}

.export-check input:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), .9);
  outline-offset: 2px;
}

.export-select {
  min-height: 40px;
  width: 100%;
  padding: 0 .6rem;
  font-family: var(--sans);
  font-size: .85rem;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 0;
}

.export-select:focus-visible {
  border-color: var(--accent);
  outline: 2px solid rgba(var(--accent-rgb), .9);
  outline-offset: 1px;
}

.export-actions {
  display: flex;
  flex-wrap: wrap;
  gap: .75rem;
  border-top: 1px solid var(--border);
  padding-top: 1.25rem;
}

/* ── Print / Save as PDF ──────────────────────────────────
   The printed page is a résumé, not a screenshot of a website: no nav, no
   controls, no colour fills, and links that still work on paper because the
   URL is printed after the text. */
@media print {

  /* Page breaks. A heading stranded at the foot of a page, or one row of a table
     carried alone onto the next, both read as an error in the document rather
     than an artefact of pagination. `orphans`/`widows` are honoured by print
     engines for block content; `break-after: avoid` keeps a heading with the
     thing it names.

     Deliberately not `break-inside: avoid` on a whole table: several here are
     longer than a page, and forbidding a break inside them would push the entire
     table to a fresh page and leave a half-page of white above it. */
  p,
  li,
  dd {
    orphans: 3;
    widows: 3;
  }

  .section-title,
  .subhead,
  thead {
    break-after: avoid-page;
    page-break-after: avoid;   /* the older name, still what some engines read */
  }

  tr,
  .masthead-meta > div {
    break-inside: avoid-page;
    page-break-inside: avoid;
  }

  /* The new-tab marker is an affordance about clicking. Nothing on paper is
     clickable, and the URL is printed after the link text anyway. */
  a[target="_blank"]::after {
    content: none;
  }

  /* Ink is expensive and cream backgrounds print as grey haze.
     Sizes follow résumé convention: body 10–12pt, headings 14–16pt. */
  html,
  body {
    background: #fff;
    color: #000;
    font-family: var(--sans);
    font-size: 11pt;
    line-height: 1.35;
  }

  .site-header,
  .site-footer,
  .skip-link,
  .filter-row,
  .status,
  .scroll-hint,
  /* A verification URL is 100+ characters of hash and nobody types one. The
     print rule below prints only `href^="assets"`, so on paper "Verify" would be
     a word with no destination. The separator is inside the span, so both leave
     together and the cell does not end in a dangling dot. */
  .cert-verify,
  .resume-actions,
  .fold-controls,
  .copy-btn,
  .export-dialog,
  .sort-btn::after,
  /* Everything below is a control, not content. A printed page cannot be
     filtered, searched or toggled, so a chip on paper is an instruction the
     reader cannot follow (Rulebook §1.1). The rows those controls were
     filtering still print — only the controls go. */
  .topic-chips,
  .year-bar,
  .view-switch,
  .compare-bar,
  .detail-btn,
  .anchor-btn,
  .filter-row-multi,
  #cert-status,
  #year-status,
  .search-dialog,
  .jump-note,
  .colophon,
  /* The button is a control; the code it opens prints in the masthead instead. */
  .qr-btn {
    display: none !important;
  }

  /* The printed QR floats, so it sits beside the masthead text instead of adding
     a band above it — `estimateLines()` counts characters, an image has none, so
     any height it added would be height the line budget could not see.

     Two rules keep that promise, and both were measured rather than assumed. In
     résumé mode the fact list drops to its two [data-resume] rows and the whole
     masthead comes down to **21.6mm**, so a 22mm code overhung it by 0.4mm and
     leaked into About. `flow-root` fixes the leak by making the masthead contain
     its own float — but containment turns an overhang into *height*, and at 20mm
     that was 2.5mm of page the character-counting estimate could not see.

     So the code is sized to fit inside the shortest masthead the résumé can
     produce: 18mm plus a 1mm margin is 19mm against 21.6mm of text, and the
     measured masthead height is unchanged in both formats. ~0.62mm per module for
     a URL this short — comfortably scannable off paper. Any future trim to the
     masthead must re-measure this, which is what the numbers above are for. */
  .masthead {
    display: flow-root;
  }

  .masthead-qr {
    display: block;
    float: right;
    width: 18mm;
    height: 18mm;
    margin: 0 0 1mm 4mm;
    padding: 1.5mm;
    background: #fff;
    border: 0.2mm solid #000;
  }

  /* Sections the reader unticked in the export dialog. */
  .print-hidden {
    display: none !important;
  }

  /* A row hidden by a transient filter comes back on paper. The filter control
     itself does not print, so a PDF showing three of seven certificates would
     give no reason for the other four being missing — it would read as three
     being all there are. The export dialog's checkboxes are the mechanism for
     choosing what prints; a filter is for looking, not for deciding.

     The view modes are deliberately NOT undone here: that toggle is visible,
     named, and stays on screen next to what it changed, so printing it is
     printing what the reader chose to look at. */
  .filtered-out {
    display: table-row !important;
  }

  /* And the block that emptying those rows collapsed comes back with them. A
     PDF exported while the year bar is on 2023 must not quietly drop two
     affiliations — the year bar does not print, so nothing on the page would
     explain the gap. Same reasoning as the rule above, one level up.
     No !important, deliberately: this rule sits after the screen one in the
     file and matches it for specificity, so it wins on order alone — while
     `.print-resume .resume-empty` (two classes) still beats it. A block the
     résumé curated away must stay away; curation IS a decision about what
     prints, and a filter is not. */
  .block-empty {
    display: revert;
  }

  /* Focus mode is reversed too, for the third time and the same reason: the
     Exit control lives in the header, the header does not print, so a PDF taken
     while focused would be one section with nothing on the page to say the
     other seven were set aside. Focus is a reading posture, not a decision
     about what a document contains.

     The `.print-hidden` guard below is not optional. `body.focus-mode .section`
     carries two classes and an element, so it out-ranks a bare `.print-hidden`
     and would print sections the reader had explicitly unticked in the export
     dialog. Re-stating it at matching specificity is what keeps the dialog the
     only thing that decides what prints. */
  body.focus-mode .masthead,
  body.focus-mode .section:not(.focus-target) {
    display: revert;
  }

  body.focus-mode .section.print-hidden {
    display: none !important;
  }

  /* A highlight left over from a search is a colour on paper that means nothing
     to whoever is holding it. */
  .hit-flash {
    animation: none;
    background: none !important;
  }

  /* New blocks, all content rather than chrome. */
  .ascii-wrap {
    border: .5pt solid #000;
    padding: 4pt;
    overflow: visible;
  }

  .ascii-art {
    font-size: 7.5pt;
  }

  .detail-list {
    display: block;
    border-top: .5pt solid #000;
    padding-top: 4pt;
    font-size: 10pt;
  }

  .detail-list div {
    display: block;
    margin-bottom: 2pt;
  }

  .detail-list dt {
    display: inline;
    font-size: 9pt;
    font-weight: 700;
  }

  .detail-list dt::after {
    content: " — ";
  }

  .detail-list dd {
    display: inline;
    margin: 0;
  }

  .stat-row {
    display: block;
    margin-bottom: 4pt;
    font-size: 10pt;
  }

  .stat-row div {
    display: inline;
  }

  .stat-row dt {
    display: inline;
    font-size: 9pt;
  }

  .stat-row dt::after {
    content: " ";
  }

  .stat-row dd {
    display: inline;
    font-weight: 700;
  }

  .stat-row dd::after {
    content: "   ·   ";
    font-weight: 400;
  }

  .stat-row div:last-child dd::after {
    content: "";
  }

  /* The bar is block characters, so it prints as text like everything else —
     no image, no background colour a printer would drop. */
  .bar {
    color: #000;
  }

  .featured-list {
    border: .5pt solid #000;
  }

  .featured-item {
    display: block;
    border-bottom: .5pt solid #000;
    padding: 3pt 4pt;
    break-inside: avoid;
  }

  .featured-name {
    font-weight: 700;
  }

  .featured-lang::before {
    content: " — ";
  }

  .featured-desc {
    display: block;
  }

  main {
    padding: 0;
    max-width: none;
  }

  /* The masthead becomes the letterhead. */
  .masthead {
    padding: 0 0 .75rem 0;
    border-bottom: 1.5pt solid #000;
    margin-bottom: 1rem;
  }

  .masthead-name {
    font-size: 20pt;
    letter-spacing: 0;
  }

  .masthead-role {
    font-size: 10pt;
    color: #000;
    letter-spacing: .06em;
  }

  .masthead-meta {
    gap: .4rem 1.5rem;
    margin-top: .75rem;
  }

  .masthead-meta dt {
    font-size: 8.5pt;
  }

  /* a field label, not body copy */
  .masthead-meta dd {
    font-size: 10pt;
  }

  .section {
    padding: .9rem 0 0 0;
    scroll-margin-top: 0;
  }

  /* A section heading stranded at the foot of a page is a wasted page. */
  .section-title {
    font-size: 14pt;
    letter-spacing: .04em;
    break-after: avoid;
    page-break-after: avoid;
  }

  .section-title .num {
    color: #000;
  }

  .section-summary::after {
    display: none;
  }

  /* no +/− marker on paper */
  .subhead {
    font-size: 11pt;
    color: #000;
    break-after: avoid;
    page-break-after: avoid;
  }

  /* ── Tables stay tables on paper ────────────────────────
     The printed grid mirrors the screen's McMaster grid: same columns, same
     order, a hairline rule on every cell edge so a value can never be read
     against the wrong heading.

     This replaced an earlier one-line-per-row linearisation aimed at applicant
     tracking systems, which parse a multi-column table badly. That trade is
     now made the other way, deliberately: this PDF is written for a person to
     read, and `assets/resume.pdf` remains the plain copy to hand a portal.
     Do not reintroduce the linearisation without moving that copy too. */
  .table-wrap {
    overflow: visible;
    border: 0;
    background: transparent;
    break-inside: auto;
  }

  .grid-table {
    width: 100%;
    font-size: 10pt;
    /* the floor in §3.10; never lower */
    border-collapse: collapse;
    table-layout: auto;
    /* long cells wrap, columns self-size */
  }

  /* A table running past a page break must reprint its headings, or the
     second page is a grid of unlabelled columns. */
  .grid-table thead {
    display: table-header-group;
  }

  .grid-table tfoot {
    display: table-footer-group;
  }

  .grid-table thead th {
    position: static;
    /* sticky is meaningless on paper */
    background: #eee;
    color: #000;
    font-size: 8.5pt;
    /* a column label, not body copy */
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .04em;
    white-space: nowrap;
    padding: .12rem .28rem;
    border: .5pt solid #999;
    border-bottom: 1pt solid #000;
    /* the header/body divide is the strong one */
  }

  /* The sort control is a button on screen; on paper it is just its own text. */
  .grid-table thead th .sort-btn {
    display: inline;
    min-height: 0;
    padding: 0;
    font: inherit;
    letter-spacing: inherit;
    text-transform: inherit;
    color: #000;
  }

  .grid-table th,
  .grid-table td {
    border: .5pt solid #999;
    padding: .12rem .28rem;
    text-align: left;
    vertical-align: top;
    overflow-wrap: break-word;
    /* a long description wraps, never overflows */
  }

  .grid-table tbody th[scope="row"] {
    font-weight: 700;
    color: #000;
  }

  /* The screen drops the outer edges so the wrapper's own border can supply
     them. That wrapper has no border on paper, so the edges are put back —
     matching the selectors that removed them, or specificity keeps them off. */
  .grid-table th:last-child,
  .grid-table td:last-child {
    border-right: .5pt solid #999;
  }

  .grid-table tbody tr:last-child>* {
    border-bottom: .5pt solid #999;
  }

  .grid-table thead th:not(:has(.sort-btn)) {
    padding: .12rem .28rem;
  }

  /* Zebra striping prints as grey haze and the grid already separates rows. */
  .grid-table tbody tr:nth-child(even)>* {
    background: transparent;
  }

  /* A row split across a page break puts a role on one page and its dates on
     the next. Rows stay whole; the table itself may break between them. */
  .grid-table tbody tr {
    break-inside: avoid;
    page-break-inside: avoid;
  }

  /* ── One-page résumé mode ───────────────────────────────
     A second print layout, switched on by `body.print-resume` from the export
     dialog. The full record above is the archive; this is the thing a recruiter
     reads in twenty seconds.

     One page is a SELECTION problem, not a typography one. A4 at 12.7mm
     margins leaves ~272mm of live height; 10pt at 1.3 line-height is ~4.6mm a
     line, so the page holds about 59 lines. The full record is 66 table rows
     before a single heading, so no margin or font setting can fit it — rows are
     dropped instead, and the grid gives way to one line per row.

     That one-line form is the linearisation this stylesheet used to apply to
     every print. It was the wrong default but it is the right résumé: compact,
     and read correctly by an applicant tracking system, which walks a PDF's
     text in document order and interleaves the columns of a real table. */

  /* Curation is per SECTION, not per table. A section that marks anything is
     curated, so its unmarked rows and paragraphs drop — including whole tables
     that marked nothing, which is what removes the one-row Malaysia Board of
     Technologists and PETRA UTP tables from the résumé without special-casing
     them. A section that marks nothing (Skills) prints whole.
     `.resume-curated` is set in JS, since CSS cannot ask whether an ancestor
     contains a matching descendant. */
  .print-resume .resume-curated tbody tr:not([data-resume]) {
    display: none;
  }

  .print-resume .resume-curated .section-body>p:not([data-resume]) {
    display: none;
  }

  /* A table whose rows have all gone, and the sub-heading introducing it, are
     hidden together — a heading over nothing is worse than neither. Emptiness
     is not a CSS question, so JS sets this at print time. */
  .print-resume .resume-empty {
    display: none;
  }

  /* Blocks that are neither a <p> nor a table row, so the two curation rules
     above cannot reach them, and which have no place on a one-page résumé.
     Marked in the markup rather than matched by tag here, so adding another
     one is a one-word edit and not a change to this stylesheet. */
  .print-resume .resume-omit {
    display: none;
  }

  /* The Skills evidence column, dropped from the one-page résumé only.
     It is the most valuable column on screen — it turns a self-assessed level
     into something checkable — and the most expensive on paper: fifteen rows
     of it costs about eight lines of a fifty-nine line page, for detail the
     reader is about to see stated properly under Experience anyway.

     estimateLines() drops the same cells. If these two ever disagree, the
     budget readout describes a page that does not exist (§3.9). */
  .print-resume .evidence {
    display: none;
  }

  /* Thirteen rows of "Software · Python · Intermediate" waste two thirds of
     every line. Three columns is how the same list is set in resume.pdf, and
     it costs ~48mm less. Opt in per table with data-resume-columns. */
  .print-resume .grid-table[data-resume-columns="3"] tbody {
    column-count: 3;
    column-gap: 7mm;
  }

  .print-resume .grid-table[data-resume-columns] tbody tr {
    break-inside: avoid;
    -webkit-column-break-inside: avoid;
  }

  /* The letterhead collapses to the two lines resume.pdf uses: the name, then
     the contact details running inline. The five-cell grid is a screen idea. */
  .print-resume .masthead-meta {
    display: block;
    padding-top: .3rem;
    border-top: .5pt solid #999;
  }

  /* Working, Studying and Focus are already stated by About, Education and the
     role line; on a résumé the letterhead is location and contact only. */
  .print-resume .masthead-meta>div {
    display: none;
  }

  .print-resume .masthead-meta>div[data-resume] {
    display: inline;
  }

  .print-resume .masthead-meta>div[data-resume]~div[data-resume]::before {
    content: " | ";
    color: #666;
  }

  .print-resume .masthead-meta dt {
    display: none;
  }

  /* "From", "Contact" — the values say it */
  .print-resume .masthead-meta dd {
    display: inline;
    font-size: 9.5pt;
  }

  .print-resume .masthead-role {
    font-size: 9pt;
    margin-top: .2rem;
  }

  .print-resume .grid-table {
    font-size: 10pt;
    display: block;
    line-height: 1.3;
  }

  .print-resume .grid-table thead {
    display: none;
  }

  /* a column label means nothing in a line */
  .print-resume .grid-table tbody {
    display: block;
  }

  .print-resume .grid-table tr {
    display: block;
    padding: .12rem 0;
    border: 0;
    break-inside: avoid;
    page-break-inside: avoid;
  }

  .print-resume .grid-table th,
  .print-resume .grid-table td {
    display: inline;
    border: 0;
    padding: 0;
    background: transparent;
  }

  /* The row's own label leads in bold; the rest follow after a middot, drawn
     between cells so it never precedes the first one. */
  .print-resume .grid-table tbody th[scope="row"] {
    font-weight: 700;
  }

  .print-resume .grid-table tbody th[scope="row"]+td::before,
  .print-resume .grid-table tbody td+td::before {
    content: " · ";
    color: #666;
  }

  /* Everything tightens: this mode is buying lines. */
  .print-resume .section {
    padding: .5rem 0 0 0;
    border: 0;
  }

  .print-resume .section-title {
    font-size: 12pt;
    margin-bottom: .25rem;
  }

  .print-resume .subhead {
    font-size: 10pt;
    margin: .35rem 0 .15rem 0;
  }

  .print-resume .masthead {
    padding-bottom: .4rem;
    margin-bottom: .5rem;
  }

  .print-resume .masthead-name {
    font-size: 17pt;
  }

  .print-resume .masthead-meta {
    margin-top: .4rem;
    gap: .2rem 1.2rem;
  }

  .print-resume .section-body p {
    font-size: 10pt;
    margin: .3rem 0;
  }

  .print-resume .note {
    font-size: 9.5pt;
    margin: .3rem 0;
    padding: .2rem 0 .2rem .5rem;
  }

  .print-resume .table-wrap {
    margin: .2rem 0;
  }

  .note {
    border-left-color: #999;
    font-size: 10pt;
  }

  .hint {
    display: none;
  }

  .section-body p {
    font-size: 11pt;
  }

  a {
    color: #000;
    text-decoration: none;
  }

  /* Print the destination of links a reader might actually need to type in.
     Deliberately not applied to the Projects table — 22 repo URLs in the
     margin is noise, and the repo names are searchable on their own. */
  .masthead a[href^="http"]::after,
  .masthead a[href^="mailto"]::after,
  #links a[href^="http"]::after,
  #certificates a[href^="assets"]::after {
    content: " (" attr(href) ")";
    font-size: 8.5pt;
    color: #444;
    word-break: break-all;
  }

  /* `@page` cannot be scoped to a class, so the narrower résumé margin is the
     one declared, and the full record pays the difference back as padding on
     <main>. 12.7mm is 0.5in — the floor stated in §3.10, sat on exactly. */
  @page {
    margin: 12.7mm;
  }

  body:not(.print-resume) main {
    padding: 1.3mm;
  }
}

/* ── Reduced motion ──────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }

  /* The shimmer is the whole point of the skeleton; without motion it becomes
     a plain static bar, which still communicates "content is coming". */
  .skeleton-bar {
    background: var(--surface-2);
  }
}
/* ── Windows High Contrast / forced colours ───────────────
   The OS replaces the whole palette, so anything that carried meaning purely as
   a background fill stops carrying it: a pressed chip and an unpressed one
   become the same shape, and `--accent-soft` behind a search hit disappears.
   Restated as borders and system colour keywords, which forced-colors keeps.

   The language bars need no rule — they are the block character `█`, text rather
   than a painted box, which is exactly why they were drawn that way (layout.md
   §4). A CSS-width bar would have vanished here. */
@media (forced-colors: active) {

  /* State that was a fill becomes a border. */
  .chip[aria-pressed="true"],
  .header-search,
  .fold-toggle,
  .sort-btn[aria-expanded="true"] {
    border: 2px solid ButtonText;
  }

  /* Where you are, and what you matched, must survive the repaint. */
  .site-nav a[aria-current="true"] {
    border-bottom: 3px solid Highlight;
  }

  .hit-flash,
  .search-hit button:hover,
  .search-hit button:focus-visible {
    outline: 2px solid Highlight;
    outline-offset: -2px;
  }

  ::highlight(search-hit-mark) {
    background-color: Highlight;
    color: HighlightText;
  }

  /* A section a view mode emptied is marked with colour and a bracket; keep the
     mark when the colour is gone. */
  .site-nav a[data-empty] {
    border-bottom: 1px dotted GrayText;
  }

  /* Focus rings are drawn from the accent, which no longer exists here. */
  :where(a, button, [tabindex]):focus-visible,
  .table-wrap:focus-visible {
    outline: 2px solid Highlight;
  }
}
