/* Design tokens. Every value that carries a design decision — colour, type
   scale, spacing, measure, the optical offsets someone had to look at to set —
   lives in these :root blocks, and every rule below reads from here. That is
   what keeps the field reversible: switching the page from a light field to a
   dark one is a change of values, not a rewrite.

   The line is drawn at "carries a decision". A focus ring's 2px, a code chip's
   corner radius and an underline's offset stay at the point of use: nobody
   reconsiders the page by changing them, and hoisting them would fill this
   block with constants that only ever hold one value. */

:root {
  /* ---- Field: light — the default, and what a system with no stated
     preference gets. The dark field is further down. --------------------------------
     Every ink is measured against BOTH surfaces it can land on, and the worse
     of the two is what counts. 4.5:1 is a hard floor, not a target: a token
     that clears the canvas can still fail the raised surface, which is where
     the first version of the accent went wrong.
     Two ratios in this file decided the design rather than describing it, and
     neither is in the list below: 2.94, which is what the old fill measured
     and why it could not carry a word, and 2.76, which is the same petrol
     against the dark canvas and why that field lifts the hue instead of
     reusing it. Both are written beside the accent they belong to. */

  /* Both fields still ship, but the page no longer lets the operating system
     choose between them, so `light dark` would be a lie told to the UA: it
     would paint scrollbars, the selection highlight and form-control chrome
     from the system preference while the page painted itself from a stored
     choice, and the two would disagree on exactly the readers who set a
     preference. The attribute block below restates this as `dark` for the
     reader who asks. */
  color-scheme: light;

  --canvas:        #F4F1EA;   /* warm bone */
  --canvas-raised: #EDE8DE;

  /*                             canvas   raised */
  --ink:        #12100E;      /*  16.83    15.55 */
  --ink-muted:  #55504A;      /*   7.07     6.53 */
  --ink-quiet:  #6E675F;      /*   4.94     4.56 — the lightest ink still
                                  legible as body text, and it clears the floor
                                  by 0.06 on the raised surface. Lightening it
                                  during type work drops it below AA. */

  --rule:        color-mix(in srgb, var(--ink) 18%, transparent);
  --rule-strong: color-mix(in srgb, var(--ink) 42%, transparent);

  /* One accent, and it is ink rather than a fill. This was vermilion, and it
     was two tokens, because #FF4D1F measures 2.94 on the canvas and cannot
     carry text at all — the page needed a second, darker hex just to write a
     link in the same colour as its own banding.

     The hue changed for a reason the page states about itself: this is
     something to stop and read. A long-wavelength accent at full chroma over a
     large area is the single strongest competitor a body of text can have for
     the reader's fixation, and the page was spending it on two full-bleed
     bands. Deep petrol is cool, quiet, and — the constraint that actually
     decided it — semantically neutral. The accent colours `tier 3 · never
     runs` in the log, and a green there would read as a pass while a red would
     read as an error. Neither is what tier three is: it is a boundary held.

     One token now does both jobs, where the fill needed two: it held a second,
     darker hex only for as long as the first could not carry a word, and a
     second name holding the same value is a fork in the palette that nothing
     is standing at.

     The depth was chosen twice, because the first choice measured the wrong
     thing. #0F3B4C shipped at 10.64 on the canvas — more than twice the floor,
     and wrong. Contrast against the background decides whether a reader can
     read something; contrast against the INK BESIDE IT decides whether they
     see it as colour at all, and #0F3B4C stood 1.58:1 from a body ink at
     16.83. It was legible and invisible at the same time: the page looked like
     the accent had been deleted rather than changed. This one stands 2.87:1
     from the ink and still clears the floor on both surfaces with 1.37 and
     0.92 to spare. Both numbers are constraints. Only the second one is
     audited by anything, which is exactly why it is the one that slipped. */
  --accent:      #186480;     /*   5.87     5.42 */
  --focus-ring:  var(--accent);

  /* The hero's fill, and it is a second value rather than the accent reused,
     because a fill and a thin line want opposite things. A fill wants to be
     dark enough to carry light text; a line on the canvas wants to be light
     enough not to read as the black beside it. #0F3B4C is 10.64 against the
     canvas and carries bone at 10.64; as a hairline it stood 1.58 from the ink
     and looked like nothing. One token cannot do both, and pretending it can
     is what produced a page that looked as though the colour had been removed.

     Only the hero takes it. #ai-first opens on 3px of --accent and stays that
     way: a masthead is four lines someone looks at, and that section is seven
     hundred words someone reads. The argument about colour competing with text
     is true of the second and not of the first, and applying it to both was
     the mistake this pair undoes. */
  --surface:     #0F3B4C;     /*  10.64 against the canvas */
  --surface-ink: #F4F1EA;     /*  10.64 on the fill */

  /* The rule over the location sits on the fill, not on the canvas, so it
     derives from the ink that fill carries. 68% rather than the 18% and 42%
     the canvas rules use, and it is chosen against the darker of the two
     fills: 5.82 on the light field's, 3.23 on the dark field's. 64% is where
     3:1 is first cleared there, by 0.04, which is a rounding result rather
     than a margin — this is the first step that clears it by something. A
     percentage picked against one fill and left to land where it lands on the
     other is a constant pretending to be a relationship. */
  --rule-on-surface: color-mix(in srgb, var(--surface-ink) 68%, transparent);

  --code-tint:       color-mix(in srgb, currentColor 9%, transparent);
  /* The strike through a command the hook refused. Derived from the tier
     colour so it tracks the accent, and softened so the command stays
     readable under it — it was refused, not deleted.

     75%, and the number moved with the accent rather than surviving it. At 55%
     this composited to 3.10:1 on the canvas under the old deep petrol — over
     the 3:1 that 1.4.11 asks of a mark carrying information, but only just.
     The lighter accent took it to 2.42, under the floor, and nothing in this
     repository measures a derived token: `color-mix()` is resolved by the
     engine, so the contrast script sees `--accent` and never sees this. 75%
     puts it at 3.54 and 3.36 on the two light surfaces and 5.02 and 4.67 on
     the two dark ones. A percentage tuned against one hex is a constant
     pretending to be a relationship. */
  --struck:          color-mix(in srgb, var(--accent) 75%, transparent);

  /* ---- Type ---------------------------------------------------------------
     Only the display face is self-hosted. Body copy runs on the reader's own
     system face, which costs nothing to download and is the one font already
     rendering when the page arrives. */

  --font-display: "Inter Tight", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-body:    system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono:    ui-monospace, "Cascadia Mono", "SF Mono", Menlo, monospace;

  /* Fluid modular scale. Each step interpolates between a phone and a wide
     desktop, so no step needs a breakpoint of its own. */
  --t-micro:   clamp(0.72rem, 0.69rem + 0.13vw, 0.79rem);
  --t-small:   clamp(0.83rem, 0.79rem + 0.18vw, 0.92rem);
  --t-body:    clamp(1.00rem, 0.96rem + 0.20vw, 1.10rem);
  --t-lead:    clamp(1.20rem, 1.10rem + 0.50vw, 1.50rem);
  --t-h3:      clamp(1.35rem, 1.20rem + 0.75vw, 1.85rem);
  --t-h2:      clamp(1.80rem, 1.45rem + 1.75vw, 2.90rem);
  --t-figure:  clamp(2.00rem, 1.55rem + 2.25vw, 3.40rem);
  /* The command line. Larger than body copy because it is the thing being
     read in the scene, and monospaced type reads smaller at equal size. */
  --t-cmd:     clamp(0.92rem, 0.72rem + 0.95vw, 1.45rem);
  --t-display: clamp(2.60rem, 1.60rem + 5.00vw, 6.00rem);

  --leading-tight: 1.02;
  --leading-snug:  1.28;
  --leading-body:  1.62;

  --tracking-display: -0.028em;
  --tracking-heading: -0.018em;
  --tracking-label:    0.22em;
  --tracking-caps:     0.08em;
  --tracking-mono:     0.06em;

  /* How far the bullet mark drops from the top of its line box to sit on the
     x-height of the first line. Optical, so it is a value someone has to see to
     set — which is exactly why it belongs here and not buried in a rule. */
  --bullet-drop: 0.62em;

  /* ---- Space --------------------------------------------------------------
     One unit, everything else a multiple of it. A magic number anywhere in a
     later rule means the rhythm has been broken, which on either field is
     visible rather than merely untidy. */

  --rhythm: 1.5rem;

  --space-1: calc(var(--rhythm) * 0.25);
  --space-2: calc(var(--rhythm) * 0.5);
  --space-3: var(--rhythm);
  --space-4: calc(var(--rhythm) * 1.5);
  --space-5: calc(var(--rhythm) * 2);
  --space-6: calc(var(--rhythm) * 3);
  --space-7: calc(var(--rhythm) * 4);
  --space-8: calc(var(--rhythm) * 6);

  /* ---- Grid ---------------------------------------------------------------
     Twelve columns, divided asymmetrically: 1–4 carry the section label, 5 is
     left empty to do the separating, 6–12 carry the content. */

  --gutter: clamp(var(--space-4), 4vw, var(--space-7));

  --page-max: 78rem;
  --page-pad: clamp(var(--space-3), 5vw, var(--space-7));
  --measure:  68ch;

  /* Narrowest a figure may get before the band drops to fewer columns. Set by
     the longest figure, "~100 users", not wanting to break. */
  --figure-min: 14rem;

  --hairline: 1px;
  /* A promise the header keeps, not a guess about it: everything that has to
     clear the header — scroll-padding, the sticky section labels, the scene's
     pinned stage — offsets by this. Below 62rem the header wraps to two rows,
     so a flat 72 pinned the stage under an opaque sticky bar and, worse, told
     the scene's release thresholds the band was taller than it is. Measured at
     both widths and rounded up onto the rhythm scale.

     3.5 and not the 3.25 this carried before the theme control existed. The
     header was measured again with the control in it, across thirteen widths:
     without it the real header ran 74px at 320 and 77px at 900 — the growth is
     --t-small, which is a clamp on vw, taking the two text rows up with the
     viewport. Against a promise of 78 that left **one pixel** at the worst
     width, and the control needs 24 of them to clear WCAG 2.2 SC 2.5.8.

     Above 62rem this value does not move, and does not need to: `min-height`
     does the work there (see .site-header) and the real content sits well
     under 72, so the control fits inside the header that already exists.

     What the extra 6px costs is checkable and was checked, and the first
     version of this sentence got its own terms wrong: it paired a 586px band
     measured at 992 with the 84px header, calling that the stacked case. It is
     neither. 586 at 992 is the side-by-side log, and at 992 the header is 72,
     because min-height takes over at 62rem. The conclusion survived only
     because the substitution happened to be conservative, which is luck, not
     method. The two honest worst pairs are 573 + 84 below 62rem and 621 + 72
     above it — 705 and 741 with the drift — both inside the 752px the release
     guard allows. The guard threshold does not move. */
  --header-h: calc(var(--rhythm) * 3.5);

  /* ---- Motion -------------------------------------------------------------
     Every dial is here because every one is a decision, and every one has a
     ceiling that the value alone does not show.

     The ceiling has the same shape every time, and it bit three times before
     it was understood: a `cover` range measures the subject's whole journey up
     the viewport, and the last subjects on the page never finish that journey
     -- there is no scroll left below them. Progress at maximum scroll is

         D / (viewport - scroll-padding-top + subject height)

     where D is the distance from the subject to the bottom of the document.
     The scroll-padding term is easy to miss and it is not decoration:
     `view-timeline-inset` defaults to `auto`, which means "use the scroll
     padding", and this page sets 96px of it for the sticky header. **That
     couples this ceiling to --header-h.** Shrink the header and every cover
     range gets less room. Nothing else in the file would tell you that.

     A range that ends past the reachable value never completes, and
     `fill: both` then holds the element part-way for good. */

  /* Travel on the way in. Doubled from --space-2 (12px), which was too small
     to register even once the range was long enough to notice. */
  --rise: var(--space-3);

  /* Where the rise ends -- a LENGTH, not a percentage, and that distinction is
     the whole point rather than a detail.

     A percentage divides by the viewport, so its reachability depends on the
     window: at `cover 40%` the last education entry stopped completing above
     ~1057px tall and rested near opacity 0.76; at 17% it survived to ~2505px
     and still failed on a portrait 4K, resting at 0.654. Measured,
     --ink-quiet needs opacity >= 0.954 to hold 4.5:1 on the canvas and >= 0.997
     on the raised surface -- so for that token **any** partial rest state is a
     contrast failure, and no percentage can be safe at every window size.
     (0.993 stood here for a while and did not reproduce: composited at 0.993
     the token measures 4.4964, under the floor. The paragraph's point was
     always that the margin is nil, and it is nil by more than it said.)

     A length divides by nothing. D is a property of the document, not the
     window: 428px behind the binding subject at desktop widths, 550px at 390.
     300px clears every subject at every viewport tested, from 390x844 to
     2160x3840 to 1440x6000, with about 30% headroom against the narrowest.

     It also holds the duration still. Under a percentage the rise spanned
     140px on a laptop and 640px on a 4K portrait -- six wheel notches, the
     crawl this file argues against a few hundred lines down. A length is the
     same everywhere. */
  --rise-to: 300px;

  /* A figure's mask opens over 75% of its own entry, each one 8% behind the
     last, so four masks sharing a row do not read as a single flash.
     Percentages are safe here because the timeline is `entry`, which has no
     viewport-dependent ceiling: it completes when the subject is fully in view.
     The ceiling it does have is arithmetic -- the last figure ends at
     span + 3 x lag, and entry stops at 100%, so 99% leaves one point of margin.
     A range end above 100% parses without warning and simply never arrives.
     The rule below clamps it rather than trusting this paragraph to be read. */
  --sweep-span: 75%;
  --sweep-lag:   8%;

  /* Where a section rule is drawn across its passage up the viewport. Lengths
     for the same reason as --rise-to, and measured the same way: D behind the
     last hairline is 685px, so 240px has room to spare at every window size,
     and the stroke lasts the same distance on a laptop and on a 4K panel.

     These stayed percentages for a while on the argument that a part-drawn
     rule is the effect working rather than damage -- true, and it still left
     the stroke crawling over 600px on a tall panel. Harmless is not the same
     as right. */
  --draw-from:   60px;
  --draw-to:    240px;

  /* How far the reader scrolls while the scene is pinned — a length, for the
     reason every range above is a length. A runway written as 420vh makes the
     pin last a multiple of the window, so the same nine rows cost about 2,500px
     of scrolling on a laptop and 12,000 on a 4K panel in portrait: the crawl
     this file argues against, at the scale of a whole section. Added to the
     stage height instead, the pin lasts this many pixels everywhere. */
  --scene-run: 1800px;
}

/* Wide enough for one row, so the floor is the whole height. Unlayered and
   after the block it overrides: inside a layer it would lose to the :root
   above whatever the media query said — the trap the print palette hit — and
   it has to come after that block rather than interrupt it, which is not a
   style point. Opening a media query in the middle of :root closes :root early
   and sweeps every token below the cut inside the query. Brace counts stay
   balanced through that, so the check that would catch it is not the one that
   counts braces: --scene-run went undefined below 62rem, calc(100svh + var())
   became invalid, and the scene's runway silently collapsed to its content. */
@media (min-width: 62rem) {
  :root { --header-h: calc(var(--rhythm) * 3); }
}

/* Touch, and only while the header is still two rows. WCAG asks 24px of the
   theme control and Apple and Material both ask 44 on a finger, so on a coarse
   pointer the control grows and the header has to grow with it: 24 of padding,
   44 of control, the 6px row gap and the nav row come to 97, and 4.25 rhythms
   is the first step of the scale that clears it.

   The width bound is not decoration. Written as a bare (pointer: coarse) this
   would also fire above 62rem, where `min-height` already holds the header at
   72px and a 44px control fits inside it untouched — a tablet in landscape
   would grow a 30px taller header to solve a problem it does not have.

   `not (min-width: 62rem)` rather than `max-width: 61.99rem`, which was the
   first form written here and left a hole: 61.99rem is 991.84px, so a viewport
   between that and 992 — reachable with fractional zoom — matched neither
   query, and got the 44px control with the 84px header behind it. Negating the
   other query cannot leave a gap, because there is nothing between a condition
   and its negation.

   Its own block rather than a query inside :root, for the reason the note
   above the 62rem block gives: a media query opened inside :root closes it
   early, and every token below the query silently stops existing. */
@media (pointer: coarse) and (not (min-width: 62rem)) {
  :root { --header-h: calc(var(--rhythm) * 4.25); }
}

/* Dark field, chosen by the reader and nothing else. This followed
   prefers-color-scheme until now, which was the same courtesy the page extends
   to prefers-reduced-motion — the system said which field it wanted and the
   page obeyed. The owner asked for light to be the page's default whatever the
   system says, so the system is no longer consulted and the choice moved into
   a control in the header.

   Say the cost out loud rather than dress the change as a pure gain. Someone
   who set their system to dark for photophobia or migraine now gets a light
   first paint and has to click, and the click only exists because JavaScript
   ran — the system preference was honoured with none. What is bought is that
   the choice is explicit, reversible, and remembered; what is sold is that it
   was automatic. That is a trade, and the reader who loses it is the reader
   who had the strongest reason to want it.

   `screen` is still the load-bearing half, and it now carries more weight than
   it did. Under prefers-color-scheme both this block and the print block wrote
   :root at (0,1,0) and source order broke the tie; an attribute selector scores
   (0,2,0) and would beat the print block from anywhere in the file, including
   from above it. That is not theory — it is the bug this project already
   shipped once, recorded in the README, back when the dark field was
   :root[data-field="dark"]. Guarded by `screen`, the dark palette cannot reach
   paper at all, so there is no contest to lose. Verified by printing a page
   with the attribute set, with and without the guard: without it the whole
   dark palette survives onto the sheet.

   Honest scope, unchanged: this swaps the field and its contrast. It does not
   add the depth effects a light-based direction would also want; those are
   extra mechanism, not different values. */

@media screen {
  :root[data-theme="dark"] {
    /* Explicit now, and it has to be: :root above says `light`, not
       `light dark`, so nothing else would tell the UA to repaint the
       scrollbar, the selection highlight and the button chrome. */
    color-scheme: dark;

    /* Not near-black, and the type is not near-white. This field shipped at
       #0B0F0E under #EDE9E1, which is 15.93:1 — three and a half times the
       floor, and it hurt to look at. Bright type on a very dark ground
       halates: the glyphs bloom into the background, and at the hero's display
       size that is a large area of glowing edges. For a reader with
       astigmatism it is worse than uncomfortable.

       So both ends move toward each other. The canvas lifts to a relative
       luminance of 0.0074 — Material's #121212 recommendation sits at 0.0060,
       and this keeps the field's slight green cast rather than going neutral
       grey — and the ink comes down to 13.00:1. Still nearly three times the
       floor. Nothing here is a contrast problem; it was a glare problem, and
       those are not the same measurement.

       Tested before it was believed: filling the hero with --canvas-raised, so
       the type would not sit on the darkest thing on the page, was the other
       candidate fix, and it was rejected. The hero does carry a fill again —
       --surface, below — and the difference is not the seam, since both sit
       under the same sticky header. It is that a raised-grey hero reads as a
       rendering accident and a petrol one reads as a decision. Neither of them
       fixes glare: the palette was the whole of that. */
    --canvas:        #111614;
    --canvas-raised: #1A201E;

    /*                             canvas   raised */
    --ink:        #DED9CF;      /*  13.00    11.76 */
    --ink-muted:  #9A9691;      /*   6.22     5.63 */
    --ink-quiet:  #908C87;      /*   5.47     4.95 — moved because the canvas
                                    moved under it. #8A8681 held 4.93 on the
                                    old raised surface and lands at 4.58 on
                                    this one: still over the floor, but it lost
                                    most of the margin it had, and this token
                                    is the one the motion layer already leaves
                                    with none to spare. Restoring the margin,
                                    not chasing a number. Lifting the canvas
                                    costs the quiet inks first, which is what
                                    limits how far the canvas can go. */

    --accent:      #4FB8D8;        /*   7.99     7.23 — lifted rather than
                                       inverted. The light field's petrol is
                                       2.76:1 against this canvas; the same hue
                                       at high lightness is what carries. */
    --focus-ring:  var(--accent);

    /* The fill is redeclared here, and it has to be. The light field's
       #0F3B4C measures 1.52 against this canvas — a band nobody would see.
       Same fork the vermilion fill had: on a dark field the fill has to move
       toward the reader, not away. #235E7B is 2.58 against the canvas, which
       is where the old fill #A53012 sat — 2.78 against the near-black canvas
       it was written for, 2.64 against this one — and it still carries this
       field's own
       ink at 5.05.

       --surface-ink is this field's --ink rather than bone. Bone would be
       brighter inside the band than the page is outside it, which is the glare
       this field just spent a commit removing. */
    --surface:     #235E7B;        /*   2.58 against the canvas */
    --surface-ink: #DED9CF;        /*   5.05 on the fill */
  }
}


/* Inter Tight, self-hosted. Taken from the Google Fonts latin build and cut
   down to what this page can contain — ASCII, Latin-1 Supplement, and seven
   typographic marks — which took 45 KB to 31 KB. It is
   one variable file rather than two static weights, so 600 and 700 cost one
   request between them.

   Seven typographic marks, of which the copy uses two: the en and em dash. The
   curly quotes and the ellipsis are declared so the copy can grow into them,
   and all 198 declared codepoints were checked against the font's own cmap —
   a range that promises a character the file cannot draw would produce tofu
   from the subset instead of the fallback the range exists to reach.

   The unicode-range is the safety net as much as the saving: a character
   outside it renders from the fallback stack instead of as a blank box. Adding
   Greek, Cyrillic or a typographic mark outside these ranges is the one way
   this arrangement breaks, and it breaks visibly, mid-word. */
@font-face {
  font-family: "Inter Tight";
  src: url("fonts/inter-tight-subset.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0020-007E, U+00A0-00FF, U+2013-2014, U+2018-2019,
                 U+201C-201D, U+2026;
}


@layer reset, base, layout, components, motion;

@layer reset {
  *, *::before, *::after { box-sizing: border-box; }
  * { margin: 0; }

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

  /* Stripping the marker also strips the list from Safari's accessibility tree,
     so every <ul> here carries role="list" to put it back. */
  ul { list-style: none; padding: 0; }

  a { color: inherit; }

  /* A <button> inherits nothing a page like this wants. The UA gives it
     ButtonFace, a 2px outset border and — the part that bites — a font of its
     own, 13.33px Arial, because `font` is one of the few properties that does
     not inherit into form controls. Left alone it would arrive as a grey chip
     in a bone header, taller than the ~23px line the header's height budget is
     built on.

     ButtonFace is the sharper hazard. It is a system colour the UA repaints
     from `color-scheme`, so the one control on this page would change colour
     through a channel no token in :root governs and contrast.py cannot see. */
  button {
    font: inherit;
    color: inherit;
    background: none;
    border: 0;
    padding: 0;
  }
}

@layer base {
  html {
    scroll-behavior: smooth;
    scroll-padding-top: calc(var(--header-h) + var(--space-3));
  }

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

  body {
    background: var(--canvas);
    color: var(--ink);
    font-family: var(--font-body);
    font-size: var(--t-body);
    line-height: var(--leading-body);
    -webkit-font-smoothing: antialiased;
  }

  h1, h2, h3 {
    font-family: var(--font-display);
    font-weight: 700;
    line-height: var(--leading-snug);
    letter-spacing: var(--tracking-heading);
    text-wrap: balance;
  }

  h1 {
    font-size: var(--t-display);
    line-height: var(--leading-tight);
    letter-spacing: var(--tracking-display);
  }

  h2 { font-size: var(--t-h2); }
  h3 { font-size: var(--t-h3); }

  p { max-width: var(--measure); text-wrap: pretty; }

  a {
    text-decoration-thickness: from-font;
    text-underline-offset: 0.18em;
  }

  a:hover { text-decoration-thickness: 2px; }

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

  code {
    font-family: var(--font-mono);
    font-size: 0.9em;
    padding: 0.1em 0.32em;
    border-radius: 2px;
    background: var(--code-tint);
  }

  /* Visible to a screen reader, absent from the layout. The 1px clipped box is
     the technique that survives every engine: display:none and visibility:hidden
     remove the text from the accessibility tree, and moving it off-screen with a
     large negative offset makes some engines scroll to it when it takes focus.

     This is not decoration. It is the only thing feeding the accessible name of
     the theme control, and Lighthouse's `button-name` audit — which is inert
     today because the page has no <button> — goes live the moment one exists.
     Delete this class and CI fails. */
  .u-vh {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    border: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .skip-link {
    position: absolute;
    inset-inline-start: var(--space-2);
    inset-block-start: var(--space-2);
    z-index: 10;
    padding: var(--space-2) var(--space-3);
    background: var(--ink);
    color: var(--canvas);
    text-decoration: none;
    transform: translateY(-200%);
  }

  .skip-link:focus { transform: none; }
}

@layer layout {
  /* Every section is the same full-bleed grid: a background can reach the
     viewport edge while its text stays on the twelve-column measure.
     Columns 1–4 carry the section label, 5 is the gutter, 6–12 the content —
     the asymmetric pair, with the empty column doing the separating. */
  main > section,
  .site-header,
  .site-footer {
    display: grid;
    grid-template-columns:
      [full-start] minmax(var(--page-pad), 1fr)
      [content-start] repeat(12, [col] minmax(0, calc(var(--page-max) / 12)))
      [content-end] minmax(var(--page-pad), 1fr)
      [full-end];
  }

  main > section > * { grid-column: content; }

  main > section {
    position: relative;
    padding-block: var(--space-7);
  }

  /* The rule dividing two sections is an element rather than a border, because
     the motion layer draws it with scaleX and a border cannot be scaled. It is
     absolutely positioned so it never becomes a grid item and never enters
     track sizing — a ::before on a grid container otherwise takes a cell.

     Undrawn, it simply stands complete. That is the state on paper, under
     reduced motion, and in an engine without scroll-driven animation: the same
     hairline that was here before, reached by a different mechanism.

     The line is a border on a zero-height box, not a painted background, and
     that is a print decision rather than a stylistic one. Chrome's print
     dialog ships with "Background graphics" unticked, so a background-painted
     rule is at the mercy of a checkbox the reader never sees. Borders are
     foreground and print unconditionally. Headless --print-to-pdf paints
     backgrounds regardless, which makes it the optimistic test and not the
     one that decides this. */
  main > section::before {
    content: "";
    position: absolute;
    inset-block-start: 0;
    inset-inline: 0;
    height: 0;
    /* --rule-strong rather than --rule. At 18% ink a hairline is barely there
       standing still, and drawing it slowly does not help anyone see something
       they cannot see. This is the one mechanism with real duration, so it is
       the one that has to be visible enough to be worth the duration. */
    border-block-start: var(--hairline) solid var(--rule-strong);
  }

  /* Three sections opt out of the section hairline, each for its own reason.
     The hero and the figures band are filled surfaces, where the change of
     ground is the division and a hairline would outline the colour instead of
     dividing anything. #ai-first opens on the 3px accent rule its h2 carries,
     and a hairline over that doubles an edge already drawn. */
  .hero::before,
  .figures::before,
  #ai-first::before { content: none; }

  /* Space between children is a margin, not a row-gap. The sticky label below
     spans every row of its section, and a row-gap would be drawn once for each
     of those rows — including the empty ones past the last paragraph. */
  main > section:where(:not(.hero)) > * + * { margin-block-start: var(--space-3); }

  /* 62rem: the point where the wide column can still hold --measure without the
     label column squeezing dates onto two lines. Repeated as a literal because
     a media condition cannot read a custom property. */
  @media (min-width: 62rem) {
    main > section > h2 {
      grid-column: col 1 / span 4;
      /* Spanning far past the last row keeps the heading out of row one's
         height. Without it, a heading that wraps to two lines makes the first
         paragraph sit in a taller row than every paragraph below it, and the
         section opens with a gap it never repeats. 100 is more rows than any
         section here will have. */
      grid-row: 1 / span 100;
      position: sticky;
      inset-block-start: calc(var(--header-h) + var(--space-4));
      align-self: start;
    }

    main > section > *:not(h2) { grid-column: col 6 / content-end; }

    /* The first child keeps the ordinary rhythm step rather than being pulled
       flush with the label. The label is display-size, so its first baseline
       already sits roughly a step below the paragraph's; zeroing the margin
       lines up the two boxes and throws the two baselines apart. */

    /* Sections without a label column keep the full measure. The list is the
       whole mechanism, so leaving a section off it is silent: the section has
       no h2, nothing reserves the label column, and the content indents into
       the right seven columns beside five empty ones. That is exactly what
       happened to .scene when its placement was deleted as a duplicate of the
       layer-wide rule above — which it is, until this query overrides it. */
    .hero > *,
    .figures > *,
    .scene > * { grid-column: content; }
  }
}

@layer components {
  .site-header {
    position: sticky;
    inset-block-start: 0;
    z-index: 5;
    background: var(--canvas);
    border-block-end: var(--hairline) solid var(--rule);
    font-size: var(--t-small);
    row-gap: var(--space-1);
    padding-block: var(--space-2);
  }

  /* Narrow first — name and link share a row, the nav takes the one below — so
     no rule has to win a specificity contest to apply at the small end. */
  .site-header__name {
    grid-row: 1;
    grid-column: content-start / span 8;
    font-weight: 600;
  }

  .site-header__link {
    grid-row: 1;
    grid-column: col 9 / content-end;
    text-align: end;
  }

  .site-header > nav { grid-row: 2; grid-column: content; }

  .site-header nav ul {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-3);
  }

  /* The theme control. Three things about it are load-bearing rather than
     stylistic.

     `display: none` by default, revealed only under :root[data-js], which the
     head script sets. A control that needs JavaScript has to be absent when
     JavaScript is absent, or it renders as a button that does nothing — and it
     is worth the extra selector to keep the markup in index.html, where
     html-validate can see it, rather than building the element in script.

     `position: relative` so the .u-vh label resolves against the button. The
     header is `position: sticky`, so without this the absolutely-positioned
     span would take the header as its containing block.

     max(1.5rem, 24px), and the px half is the load-bearing half. SC 2.5.8
     states its threshold in CSS pixels, and that threshold does not scale with
     the reader's font size — but 1.5rem does. Chrome's "Small" setting puts the
     root at 12px, which would make a purely rem-sized control 18 CSS px and
     fail the criterion for the reader who most likely changed the setting for
     a reason. The rem stays first so the control still grows with the rhythm
     when the root grows; the px is a floor under it.

     A control sized to the header's ~23px text line would have failed by one
     pixel, and failed invisibly: the header's own links escape SC 2.5.8 by
     being `display: inline`, where the line-height exception applies, and a
     button is not inline. Lighthouse renders this page with touch emulation
     on, so it only ever audits the coarse branch below — the fine branch
     ships with no automated check behind it at all. */
  .theme-toggle {
    grid-row: 1;
    grid-column: col 11 / content-end;
    justify-self: end;
    position: relative;
    display: none;
    place-items: center;
    inline-size: max(1.5rem, 24px);
    block-size: max(1.5rem, 24px);
    color: var(--ink-muted);
    cursor: pointer;
    border-radius: 2px;
  }

  :root[data-js] .theme-toggle { display: inline-grid; }
  .theme-toggle:hover { color: var(--ink); }
  .theme-toggle svg { display: block; inline-size: 1.125rem; block-size: 1.125rem; }

  /* The icon shows the field the reader would move to, not the one they are
     in — the control is an offer, not a status light. aria-pressed carries the
     state for anyone who cannot see either icon.
     Every one of these is scoped through .theme-toggle rather than written as a
     bare class. `.theme-toggle svg` above scores (0,1,1); a lone
     `.theme-toggle__sun` scores (0,1,0) and loses to it, which shows up as both
     icons rendering at once, stacked. */
  .theme-toggle .theme-toggle__sun { display: none; }
  .theme-toggle[aria-pressed="true"] .theme-toggle__sun  { display: block; }
  .theme-toggle[aria-pressed="true"] .theme-toggle__moon { display: none; }

  /* The two columns the control borrows are given back when it is not there,
     so the no-JavaScript header is exactly the header this page shipped
     before the control existed. */
  :root[data-js] .site-header__name { grid-column: content-start / span 7; }
  :root[data-js] .site-header__link { grid-column: col 8 / span 3; }

  @media (pointer: coarse) {
    .theme-toggle { inline-size: 2.75rem; block-size: 2.75rem; }
  }

  .site-header a { text-decoration: none; color: var(--ink-muted); }
  .site-header a:hover { color: var(--ink); text-decoration: underline; }
  .site-header__name a { text-decoration: none; }
  .site-header__link a { color: var(--accent); font-weight: 600; }

  @media (min-width: 62rem) {
    .site-header {
      min-height: var(--header-h);
      align-items: center;
      row-gap: 0;
      padding-block: 0;
    }

    /* Each child names its own row. A blanket `.site-header > * { grid-row: 1 }`
       looks like it would do the job and does not: it scores below the narrow
       `.site-header > nav`, which keeps the nav on row two at every width. */
    .site-header__name { grid-row: 1; grid-column: content-start / span 4; }
    .site-header > nav { grid-row: 1; grid-column: col 5 / span 5; }
    .site-header__link { grid-row: 1; grid-column: col 11 / content-end; }

    /* The name keeps all four columns here — there is room on one row — so
       only the link gives ground, and only when the control is present. */
    :root[data-js] .site-header__name { grid-column: content-start / span 4; }
    :root[data-js] .site-header__link { grid-column: col 10 / span 2; }
    .theme-toggle { grid-column: col 12 / content-end; }
  }

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

  /* Nothing in this section declares a colour of its own — the name, the role,
     the stack line, the polyglot line and the location all inherit --ink from
     the body. So the fill needs `color` and nothing else: redefining --ink here
     would not reach them, because their colour was computed at the body and
     comes down as an inherited value rather than as a fresh var() lookup. That
     is not a detail, it is the difference between this working and the type
     staying black on the fill. */
  .hero {
    background: var(--surface);
    color: var(--surface-ink);
    /* Two tokens that only mean something on a fill. The location's rule
       derives from the ink the fill carries, and the focus ring stops being
       --accent, which measures 1.81 against this fill — under the 3:1 WCAG
       asks of a non-text indicator. That exact ring-invisible-on-its-own-fill
       bug shipped here once already, in vermilion. */
    --rule-strong: var(--rule-on-surface);
    --focus-ring:  var(--surface-ink);
    padding-block: var(--space-8) var(--space-7);
  }

  .hero h1 { margin-block-end: var(--space-4); }

  .hero__role {
    font-family: var(--font-display);
    font-size: var(--t-lead);
    font-weight: 600;
  }

  .hero__core { font-size: var(--t-lead); }

  .hero__polyglot {
    margin-block-start: var(--space-1);
    font-size: var(--t-body);
  }

  .hero__place {
    margin-block-start: var(--space-4);
    padding-block-start: var(--space-2);
    /* --rule-strong, which the hero has rebound to --rule-on-surface, so this
       line reads the ink of whatever it is sitting on rather than the ink of
       the canvas. The declaration does not change when the fill comes and
       goes; only what the token resolves to does. The note below hangs the
       whole argument for four empty columns on this line being visible, so it
       is the one hairline on the page that cannot be allowed to go quiet. */
    border-block-start: var(--hairline) solid var(--rule-strong);
    font-size: var(--t-micro);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
  }

  /* The rule over the location crosses the whole measure, and the location
     itself sits at the far end of it.

     The name reaches about column eight and everything under it is set in the
     first six, so columns nine to twelve stand empty — flat colour when this
     was written, bare canvas now, and the argument does not care which.
     Empty space reads as a choice when something holds it and as an
     unfinished page when nothing does — and in this direction the thing that
     holds it is the rule, which is the whole argument for a visible grid. One
     hairline across twelve columns states the measure; the location at its
     right end gives the eye somewhere to land.

     Nothing new was written to do this. The rule and the line were already
     there, four columns wide and stacked at the left. */
  @media (min-width: 62rem) {
    .hero__place {
      grid-column: content;
      /* It is a <p>, so it inherits the 68ch measure, and the measure caps the
         box the border is drawn on — the grid column went to twelve and the
         rule stopped at 560px anyway. Measured, not assumed: this looked like
         the grid-column had failed. */
      max-width: none;
      text-align: end;
      /* Letter-spacing is applied after the last glyph too, and that trailing
         space is inside the line box — so end-aligned text stops about 3px
         short of the rule it is meant to line up with. Pulling the box out by
         one tracking step puts the last letter on the rule's end, which is the
         whole point of moving it here. */
      margin-inline-end: calc(var(--tracking-label) * -1);
    }
  }

  /* ---- The policy scene ---------------------------------------------------
     An audit log: each command the hook intercepted, paired with the verdict it
     returned. Deliberately not a terminal — green-on-black is the most copied
     look in this field and the plan rejected that direction by name. It is set
     in the page's own voice instead: the command in mono, the verdict in the
     display face, a hairline between rows. A record, not a costume.

     Everything below is the state every reader gets. The runway and the pin
     that turn it into a scene live with the animation, in the motion layer, so
     an engine without scroll-driven animation is not given several screens of
     empty scrolling to reach a list it could have read in place. */

  .scene::before { content: none; }   /* the log's own rule is the edge */

  /* The only micro-label on the page that is not uppercased, because the only
     one whose text is an identifier rather than a word. `PreToolUse` is the
     real name of the hook, and the capitals in the middle are how you
     recognise it; uppercased it renders PRETOOLUSE, which a reader has to
     parse letter by letter. It is also the first mono string on the page — the
     label of the scene the whole page is built around. Shouting a word costs
     nothing. Shouting a name deletes it. */
  /* Every other micro-label on the page is uppercase, short, and carries
     --tracking-label at 0.22em, which is what wide tracking is for: it opens
     up capitals that would otherwise crowd. This one is lowercase and reads as
     a phrase, and 0.22em on a phrase pulls the letters out of their word
     shapes and slows the reader down for nothing. It kept the tracking only
     because it inherited the label register wholesale.

     --tracking-mono keeps it in the same family as its siblings without
     spending the cost on a sentence. A fifth tracking value rather than a
     literal, because every other letter-spacing in this file is a token and
     one hard-coded em would be the first place the scale stops being the
     scale. */
  .scene__label {
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    letter-spacing: var(--tracking-mono);
    color: var(--ink-quiet);
    margin-block-end: var(--space-4);
  }

  .scene__label b { color: var(--accent); font-weight: 600; }

  /* `end`, not `baseline`, and the difference is the row rule. Baseline aligns
     two faces at two sizes by their baselines and leaves their descents
     unequal, so the two boxes end at different heights and their bottom borders
     miss each other by about four pixels — a step at the column boundary on
     every row, which is the opposite of what the note below claims the
     closed-up columns buy. Aligned to their ends, both boxes finish on the same
     line and the rule is continuous by construction. The two texts then sit on
     a common descender line rather than a common baseline, which for a mono
     face beside a display face is a difference of a pixel or so — paid once,
     against a four-pixel break repeated nine times. */
  /* KNOWN UNKNOWN, written down rather than guessed at. This is a <dl> with its
     display changed, and the rule at the top of @layer base gives role="list"
     to every <ul> precisely because WebKit drops list semantics when the
     default display goes away. Whether the same happens to a description list
     under `display: grid` — nine pairs read as eighteen loose strings — was not
     testable on the machine this was built on: Windows, no WebKit. It was not
     fixed blind either, because the obvious repair is worse than the fault:
     role="list" plus role="listitem" would flatten nine term-and-definition
     pairs into a flat list, which is a claim about the structure that is false
     in every engine rather than one that may be lost in some. The degraded
     reading is still the right text in the right order — command, then the
     tier that answered it — so the failure mode is loss of grouping, not loss
     of content. Worth ten minutes on a Mac before anyone assumes it is fine. */
  .log {
    display: grid;
    grid-template-columns: 1fr auto;
    align-items: end;
    border-block-start: var(--hairline) solid var(--rule-strong);
  }

  /* No column gap, and that is the point rather than a saving. The command and
     its verdict are separate grid items, so each draws its own bottom border —
     with a gap between them the rule breaks in the middle and there is nothing
     carrying the eye from `git push origin main` across to the tier that
     answered it, which on a wide screen is eight hundred pixels away. Closed
     up, the border is one continuous line per row and reads the way a ledger
     does. The breathing room moves to padding on the command. */
  .log dt { padding-inline-end: var(--space-5); }

  /* On the rhythm scale, and deliberately not on a vh: row height decides
     whether the log fits a pinned viewport, and a viewport-proportional row
     makes that threshold move with the window instead of being a number this
     file can state. Same argument the motion tokens make about ranges. */
  .log dt,
  .log dd {
    padding-block: var(--space-2);
    border-block-end: var(--hairline) solid var(--rule);
    margin: 0;
  }

  .log dt {
    font-family: var(--font-mono);
    font-size: var(--t-cmd);
    color: var(--ink);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .log dt::before { content: "$ "; color: var(--ink-quiet); }

  .log dd {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: var(--t-small);
    white-space: nowrap;
    text-align: end;
  }

  /* The verdict carries the weight, so it carries the colour. Tier three also
     strikes the command through: the only tier where the command never ran. */
  .log dd.t1 { color: var(--ink-quiet); }
  .log dd.t2 { color: var(--ink-muted); }
  .log dd.t3 { color: var(--accent); }

  .log dt.t3 {
    text-decoration: line-through;
    text-decoration-thickness: 1px;
    text-decoration-color: var(--struck);
  }

  /* Narrow: the pair stacks. Command and verdict together run to 49 characters
     at the longest, which does not fit a phone on one line — and the command is
     the content, so ellipsing it truncates the argument. Stacked, the longest
     command is 26 characters and clears the measure with room. */
  @media (max-width: 34rem) {
    .log { grid-template-columns: 1fr; }
    .log dt {
      border-block-end: 0;
      padding-block: var(--space-1) 0;   /* the pair reads as one row; the air
                                            belongs between rows, not inside */
      padding-inline-end: 0;   /* nothing sits beside it to make room for */
    }
    .log dd { text-align: start; padding-block-start: var(--space-1); }
  }

  /* ---- Figures ----------------------------------------------------------- */

  .figures {
    background: var(--canvas-raised);
    padding-block: var(--space-6);
  }

  .figures ul {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(var(--figure-min), 1fr));
    gap: var(--space-5) var(--gutter);
  }

  .figures li {
    display: grid;
    align-content: start;
    gap: var(--space-2);
    padding-block-start: var(--space-2);
    border-block-start: var(--hairline) solid var(--rule-strong);
  }

  .figure {
    font-family: var(--font-display);
    font-size: var(--t-figure);
    font-weight: 700;
    line-height: var(--leading-tight);
    letter-spacing: var(--tracking-display);
    color: var(--accent);
  }

  /* Was .figures span:not(.figure). The exclusion matched nothing to exclude —
     .figure sits on the <strong>, never on a <span> — so today it bought an
     extra class of specificity for a contest nobody was having. It was also a
     guard: this selector at (0,1,2) now outscores .figure at (0,1,0), so a
     .figure moved onto a span would quietly lose its accent colour where the
     old form kept it. Not a case worth building for, but worth knowing the
     old selector was not pure cost. */
  .figures li span {
    font-size: var(--t-small);
    line-height: var(--leading-snug);
    color: var(--ink-muted);
  }

  /* ---- AI-first ---------------------------------------------------------- */

  /* A rule here and a fill on the hero, and the difference between the two is
     the whole colour argument on this page. The hero is a masthead: four short
     lines someone looks at, where a field of colour is the first impression
     and competes with nothing. This is seven hundred words someone reads, and
     a saturated field behind it takes the fixation the sentences need. It was
     a full-bleed band here once, for exactly that reason it should not have
     been.
     No section hairline: this rule is the edge, and a second one would double
     it. */
  #ai-first {
    padding-block-start: 0;
  }

  /* Undoes the sticky label treatment for the two elements that cross the full
     width. Only the h2 ever carried it — the sticky rule is
     `main > section > h2`, and only above 62rem — and two of its declarations
     are left standing, for different reasons.

     `inset-block-start` is inert: inset properties do not apply to a box that
     is not positioned.

     `align-self: start` is not, and the difference is the whole reason this
     note exists. It applies to a grid item whatever its position: measured, a
     static item sharing a 120px row comes out 18px with `start` and 120px with
     `stretch`. It only looks inert because these two span `full` and each
     lands alone in its row, where the row is sized by its one item either way.
     It used to have a visible consequence — put anything else in that row and
     the colour band stopped filling it — and the band is gone, so there is
     nothing left to see. The note stays because the declaration does: without
     it the next reader measures `start` against `stretch` again and reaches
     the same place. */
  #ai-first > h2,
  #ai-first > .lede {
    grid-column: full;
    grid-row: auto;
    position: static;
    max-width: none;
    margin-block-start: 0;
    padding-inline: max(var(--page-pad), calc((100% - var(--page-max)) / 2));
  }

  #ai-first > h2 {
    padding-block-start: var(--space-6);
    border-block-start: 3px solid var(--accent);
  }

  /* The label column stands empty for the length of this section — the heading
     crosses all twelve columns rather than sitting in the margin. Body text
     left indented under an
     empty column reads as an accidental tab stop, so the argument starts at the
     page margin, at the seven-column width every other section gives content. */
  @media (min-width: 62rem) {
    #ai-first > *:where(:not(h2, .lede)) {
      grid-column: content-start / span 7;
    }

    /* The rules that open each axis run the full measure instead of stopping
       with the prose. Seven columns of argument leaves five standing empty for
       most of the tallest section on the page, and a hairline that stops at
       the text edge lets that emptiness read as the page having run out. Taken
       across all twelve it reads as measured — the same move the hero makes,
       for the same reason.

       Only the headings cross. The closing line keeps its seven columns: it is
       a <p> and carries the reading measure, so widening its box would either
       cap the rule at 68ch anyway or run the sentence past the measure to buy
       a hairline. Neither is a trade worth making for the last line of the
       section. */
    #ai-first > h3 {
      grid-column: content;
    }
  }

  /* Padding rather than margin, which used to be forced: the two elements were
     separate grid rows inside one colour band, and a margin between them
     showed as a stripe of canvas splitting it. With no band the two are
     interchangeable here. It stays padding to match the h2 above, where the
     choice is still forced — that padding sits under a 3px border, and a
     margin would open the gap on the wrong side of it. */
  #ai-first > .lede {
    padding-block: var(--space-2) var(--space-5);
    font-family: var(--font-display);
    font-size: var(--t-lead);
    font-weight: 600;
  }

  /* The lede is a <p> too, and this rule would hand it a margin on top of the
     padding the rule above already spends — hence the exclusion. It is wrapped
     in :where() so it contributes no specificity: written bare, this rule outscored
     every more specific paragraph rule below it and flattened the whole section
     to one spacing. */
  #ai-first > p:where(:not(.lede)) { margin-block-start: var(--space-3); }
  #ai-first > h3 { margin-block-start: var(--space-6); }

  #ai-first > h3 + p { margin-block-start: var(--space-2); }

  #ai-first > h3 {
    padding-block-start: var(--space-2);
    border-block-start: var(--hairline) solid var(--rule-strong);
  }

  /* The first h3 in this section used to take an extra step. Back when the
     heading and the lede were a colour band, the band's own bottom padding and
     that step together left a stretch of empty canvas under the colour which
     read as the section having lost its place rather than as breathing room.
     The band is gone and so is the extra step: this h3 takes the same one as
     every other axis heading, so the rule that made it special is absent
     rather than restating the default at a higher specificity. */

  #ai-first .aside,
  #ai-first .closing {
    font-size: var(--t-small);
    color: var(--ink-muted);
  }

  #ai-first .aside { margin-block-start: var(--space-5); }

  #ai-first .closing {
    margin-block-start: var(--space-4);
    padding-block-start: var(--space-3);
    border-block-start: var(--hairline) solid var(--rule-strong);
    color: var(--ink);
    font-weight: 600;
  }

  /* ---- Roles ------------------------------------------------------------- */

  .role + .role {
    margin-block-start: var(--space-5);
    padding-block-start: var(--space-5);
    border-block-start: var(--hairline) solid var(--rule);
  }

  .role__meta {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1) var(--space-3);
    margin-block-start: var(--space-1);
    font-size: var(--t-micro);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--ink-quiet);
  }

  .role__dates { color: var(--accent); }

  .role ul {
    display: grid;
    gap: var(--space-2);
    margin-block-start: var(--space-3);
  }

  .role li {
    max-width: var(--measure);
    padding-inline-start: var(--space-3);
    position: relative;
  }

  /* A border rather than a background, for the reason given on the section
     rule above: the reader printing this has "Background graphics" off by
     default, and a bullet mark that vanishes takes the list structure with it.

     The floor for a non-text element carrying information is 3:1
     (WCAG 1.4.11), and this mark carries information: the comment one line up
     says losing it loses the list structure, which is the definition of
     informative. Claiming it decorative would clear the floor by definition
     and contradict that sentence.

     It used to need saying which of two accent tokens to use here, because the
     fill orange measured 2.94 and 1.4.11 wants 3. There is one accent now and
     it measures 5.87 on --canvas and 5.42 on --canvas-raised, so the question
     closed itself. Worth keeping the note: Lighthouse scores 100 without
     auditing 1.4.11 against a pseudo-element border, so nothing here would
     have caught the old value either. */
  .role li::before {
    content: "";
    position: absolute;
    inset-block-start: var(--bullet-drop);
    inset-inline-start: 0;
    width: var(--space-2);
    height: 0;
    border-block-start: var(--hairline) solid var(--accent);
  }

  .role__stack {
    margin-block-start: var(--space-3);
    font-size: var(--t-small);
    color: var(--ink-muted);
  }

  .role__stack-label {
    font-variant-caps: all-small-caps;
    letter-spacing: var(--tracking-caps);
    color: var(--ink);
  }

  /* ---- Stack and education ----------------------------------------------- */

  .stack dt {
    font-size: var(--t-micro);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--accent);
    padding-block-start: var(--space-2);
    border-block-start: var(--hairline) solid var(--rule);
  }

  .stack dt:first-of-type { border-block-start: 0; padding-block-start: 0; }

  .stack dd {
    margin-block: var(--space-1) var(--space-3);
    max-width: var(--measure);
  }

  /* The last entry's trailing margin would stack onto the section padding and
     make the section end lower than it begins. */
  .stack dd:last-of-type { margin-block-end: 0; }

  .education li {
    display: grid;
    gap: var(--space-1);
    padding-block: var(--space-3);
    border-block-start: var(--hairline) solid var(--rule);
  }

  .education li:first-child { border-block-start: 0; padding-block-start: 0; }
  .education li:last-child  { padding-block-end: 0; }

  .education__degree { font-weight: 600; max-width: var(--measure); }

  .education__school,
  .education__dates {
    font-size: var(--t-small);
    color: var(--ink-muted);
  }

  .education__dates {
    font-size: var(--t-micro);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--ink-quiet);
  }

  /* ---- Footer ------------------------------------------------------------ */

  .site-footer {
    background: var(--canvas-raised);
    padding-block: var(--space-6);
    row-gap: var(--space-3);
    border-block-start: var(--hairline) solid var(--rule-strong);
  }

  .site-footer > * { grid-column: content; }

  .site-footer__contact { font-size: var(--t-lead); max-width: var(--measure); }
  .site-footer__contact a { color: var(--accent); font-weight: 600; }

  .colophon {
    font-size: var(--t-small);
    color: var(--ink-muted);
    max-width: var(--measure);
  }

  @media (min-width: 62rem) {
    .site-footer__contact { grid-column: content-start / span 6; }
    .colophon { grid-column: col 8 / content-end; }
  }
}

/* Scroll-driven motion. Not Baseline: Chrome and Edge from 115, Safari from 26,
   and Firefox still has it behind a flag — call it four visitors in five. The
   fifth reads the finished page, which is the same page.

   Three guards wrap the whole layer, and each one is load-bearing rather than
   defensive:

     @media screen        — paper has no scroll to drive anything. Without this
                            the initial states below never animate away and the
                            page prints as blank sheets.
     @supports            — an engine that cannot run the animation would apply
                            the hidden state and never remove it. This is what
                            keeps Firefox from rendering invisible text.
     no-preference        — a reader who has asked their system for less motion
                            gets the page with none, not a shorter version.

   Every one of them guards the same thing: a rule that hides something is only
   safe next to the rule that brings it back. */

@layer motion {
  @media screen {
    @media (prefers-reduced-motion: no-preference) {

      /* Each mechanism is gated on the feature it actually calls, and the two
         conditions are siblings rather than one merged condition, so an engine
         missing either still gets everything the other covers. view() covers
         the headings, the rules and the figures; the scene declares a named
         view-timeline instead and carries its own block after this one. A third
         briefly guarded scroll(), and went out with the progress ruler that was
         its only caller — if anything here reaches for scroll() again, it needs
         its own @supports rather than riding on these. */

      @supports (animation-timeline: view()) {
        @keyframes rise {
          from { opacity: 0; transform: translateY(var(--rise)); }
          to   { opacity: 1; transform: none; }
        }

        /* transform-origin is what makes this read as drawn rather than grown:
           from the centre a line opens like a curtain, from the left edge it is
           laid down the way a rule is laid down. The keyword is physical and
           has no logical counterpart, so on an RTL page the stroke would run
           against the reading direction. This page is lang="en"; a translation
           would have to revisit it. */
        @keyframes draw {
          from { transform: scaleX(0); }
          to   { transform: scaleX(1); }
        }

        @keyframes sweep {
          from { clip-path: inset(0 100% 0 0); }
          to   { clip-path: inset(0); }
        }

        /* What moves is what announces a block — a figure, a heading, a label,
           the closing line — and never the prose underneath it. Two reasons,
           and the second is the one that set the list. Reading: motion on a
           paragraph you are already reading is an interruption, while motion on
           the thing that introduces it is a cue. Rendering: an animated element
           is composited, and a composited element loses subpixel antialiasing
           on the displays that still do it. Paying that on 23 subjects is
           fine. Paying it on every paragraph on the page is not. */
        #ai-first > h3,
        #ai-first > .closing,
        .role > h3,
        .role > .role__meta,
        .stack dt,
        .education li {
          animation: rise linear both;
          animation-timeline: view();
          animation-range: cover 0% cover var(--rise-to);
        }

        /* Measured, after the first version of this turned out to be invisible.
           `entry` lasts the subject's own height, so a 20px stack label
           animated over 11px of scrolling — a ninth of one wheel notch. Every
           check written for it passed: the animations attached, the progress
           tracked the scroll, the ranges resolved. None of them asked whether a
           person could see it, and nobody could. A mechanism nobody perceives
           is the same as one that was never built.

           `cover` measures the subject's whole journey up the viewport, and the
           range ends at a length rather than a share of it, so that label now
           moves over a constant 300px — three notches — on a laptop and on a
           4K panel alike. Under a percentage the same range spanned 140px on
           one and 640px on the other, which is the crawl this paragraph warns
           about arriving through the window size instead of the subject.
           What keeps cover safe here is also that every subject in this list is
           small: headings, labels, dates. On a subject taller than the viewport
           it would stretch and crawl regardless, which is why the roles animate
           their heading and their dates rather than the whole article.

           A subject already on screen at load sits part-way through its range.
           For opacity and travel that is graceful — it reads as still
           arriving. The mask below cannot afford the same thing, and does not
           use cover. */

        /* The section rules draw themselves. The subject here is the hairline
           itself, not the section it opens, so entry is useless — a one-pixel
           box finishes entering in one pixel of scroll. cover measures the
           line's whole journey up the viewport instead, and the range ends
           early so the stroke lands well before it reaches the reading zone. */
        main > section::before {
          transform-origin: left;
          animation: draw linear both;
          animation-timeline: view();
          animation-range: cover var(--draw-from) cover var(--draw-to);
        }

        /* The four figures open by mask rather than by counting: a counter
           animates the fact, a mask animates the reveal of a fact that was
           always in the markup — which is also the only version still true
           when the animation never runs. They share a row on a wide screen, so
           each one starts a step behind the last; the lag is the range shifted
           per item, not a delay, because a scroll timeline has no clock.

           Each figure is its own timeline, and that is the constraint rather
           than a preference. Hanging all four on the band's timeline made the
           sweep three times longer and re-broke the thing the mask exists to
           avoid: the band is only fully entered on windows over ~990px tall,
           so below that the numbers sat cut at first paint — measured, figure
           four 48% masked at 1440x900 and 98% masked at 390x844. Per figure,
           `entry` completes the moment that figure is fully in view, so
           nothing is ever caught half-masked.

           The cost is honest: ~88px of sweep instead of ~183px. A heading
           caught half-faded reads as arriving; a headline number caught
           half-masked reads as broken, so the shorter sweep wins. */
        .figures li {
          animation: sweep linear both;
          animation-timeline: view();
          animation-range:
            entry calc(var(--fig-lag, 0) * var(--sweep-lag))
            entry min(100%, calc(var(--sweep-span) + var(--fig-lag, 0) * var(--sweep-lag)));
        }

        /* The count is enumerated because the four figures are frozen content,
           not a list that grows. The fallback in var() above is what keeps a
           fifth item from invalidating the whole range rather than merely
           arriving unstaggered. */
        .figures li:nth-child(2)   { --fig-lag: 1; }
        .figures li:nth-child(3)   { --fig-lag: 2; }
        .figures li:nth-child(4)   { --fig-lag: 3; }
      }

      /* The scene is gated on its own condition, beside the view() block and
         not inside it, because it does not call view(): it declares a named
         timeline on the section and hangs four animations off it. Those are
         separate features, and an engine that shipped one without the other
         would apply the runway and the pin and then never move anything
         through them — three and a half screens of empty scrolling past a list.

         Both halves are tested, and that is not belt and braces: naming a
         timeline on the section and *referring* to it from an animation are two
         declarations, and it is the reference that moves anything. A guard that
         asked only for `view-timeline` would let an engine that parses the name
         but not the reference through, and hand it the runway and the pin with
         nothing travelling them — the exact failure this block exists to stop.

         Beside, not inside, is the whole of it. This block was first written
         nested in the view() guard with a comment saying it was independent of
         it, which is the opposite of what nesting does: an inner @supports
         requires the outer one, so the scene needed *both* features and the
         comment promised it needed either. Two conditions only buy independence
         when they are siblings. */
      @supports (view-timeline: --a) and (animation-timeline: --a) {
        /* The scene. A tall runway carries a sticky stage, so the reader
           scrolls *through* the policy instead of past it — the structure the
           reference sites use for a canvas, without the canvas.

           The runway and the pin are declared here rather than with the rest of
           the component, and that placement is the whole fallback strategy:
           without them the log is a list at its natural height, which is what
           an engine with no scroll-driven animation should get. Left outside
           the guard they would hand that engine three and a half screens of
           empty scrolling past a static list. Measured on the prototype before
           the fix: 3360px of scroll with nothing happening; after: 763px. */
        .scene {
          height: calc(100svh + var(--scene-run));
          padding-block: 0;
          view-timeline: --audit block;
          /* Where the timeline starts has to be where the pin starts, and by
             default it is not. `view-timeline-inset: auto` means "use the scroll
             container's scroll-padding", which is --header-h plus a step; sticky
             does not consult scroll-padding at all and pins at --header-h flat.
             Left alone the range opened 24px before the stage pinned, so the
             first command began landing while the section was still scrolling
             past. Stated explicitly, the range and the travel are the same
             1872px — --scene-run plus the header it is pinned below. */
          view-timeline-inset: var(--header-h) 0;
        }

        .scene__stage {
          position: sticky;
          /* Offset by the header, not zero. The header is sticky at zero too,
             opaque, and above this in the stacking order — a stage pinned to
             zero spends the whole scene with its first rows behind it. Every
             other sticky thing in this file already carries this offset, and
             the scene was the one that did not. */
          inset-block-start: var(--header-h);
          /* align-self: start is what makes the pin possible at all. A grid item
             stretches to its row by default, so a sticky one fills the runway
             and has nowhere to travel — it scrolls away like static content.
             Measured before this line: stage top ran to -474px, -1274px,
             -2074px as the page scrolled. The sticky section labels elsewhere
             in this file carry the same declaration for the same reason. */
          align-self: start;
          min-height: calc(100svh - var(--header-h));
          display: grid;
          align-content: center;
        }

        @keyframes land {
          from { opacity: 0; transform: translateX(calc(var(--rise) * -1)); }
          to   { opacity: 1; transform: none; }
        }

        @keyframes stamp {
          from { opacity: 0; }
          to   { opacity: 1; }
        }

        /* The log drifts upward across the whole pinned scene, the way a console
           scrolls as it fills. It is the only *continuous* motion here: the rows
           land in steps, and steps alone leave the reader with no confirmation
           that scrolling is still doing anything while the scene is pinned.
           Small on purpose — enough to feel, not enough to carry the earlier
           lines off the top, because the finished state has to show the whole
           policy at once. */
        @keyframes drift {
          from { transform: translateY(var(--space-3)); }
          to   { transform: translateY(calc(var(--space-3) * -1)); }
        }

        .log {
          animation: drift linear both;
          animation-timeline: --audit;
          animation-range: contain 0% contain 100%;
        }

        /* Each command lands, then its verdict is stamped a beat behind it —
           which is the order the hook works in, not a decorative delay.
           The last pair ends at 8 x 9% + 11% = 83%, inside the range with room.
           `contain` is measured against the runway rather than the window, so
           every percentage here resolves to the same distance on every screen —
           which is the whole reason --scene-run is a length. */
        .log dt {
          animation: land linear both;
          animation-timeline: --audit;
          animation-range: contain calc(var(--i, 0) * 9%) contain calc(var(--i, 0) * 9% + 7%);
        }

        .log dd {
          animation: stamp linear both;
          animation-timeline: --audit;
          animation-range: contain calc(var(--i, 0) * 9% + 5%) contain calc(var(--i, 0) * 9% + 11%);
        }

        /* The row index, in CSS rather than as a style attribute on every line:
           the order is presentation, and the markup should not carry it. */
        .log dt:nth-of-type(1), .log dd:nth-of-type(1) { --i: 0; }
        .log dt:nth-of-type(2), .log dd:nth-of-type(2) { --i: 1; }
        .log dt:nth-of-type(3), .log dd:nth-of-type(3) { --i: 2; }
        .log dt:nth-of-type(4), .log dd:nth-of-type(4) { --i: 3; }
        .log dt:nth-of-type(5), .log dd:nth-of-type(5) { --i: 4; }
        .log dt:nth-of-type(6), .log dd:nth-of-type(6) { --i: 5; }
        .log dt:nth-of-type(7), .log dd:nth-of-type(7) { --i: 6; }
        .log dt:nth-of-type(8), .log dd:nth-of-type(8) { --i: 7; }
        .log dt:nth-of-type(9), .log dd:nth-of-type(9) { --i: 8; }

        /* min-height is a floor, not a cap: when what the stage holds is taller
           than the band it is pinned into, the bottom of the log sits below the
           fold at every scroll position and the tier-3 lines — the payload —
           can never be read. Unpinning is the fix rather than scrolling inside
           the stage, because a scroller nested in a scroll-driven scene is a
           worse thing to hand a reader than a plain list.

           Three things add up to the height the band has to have, and leaving
           any one of them out puts the threshold under the real requirement:

             the log      621px side by side, and 723px stacked below 34rem,
                          where each pair takes two lines. Measured at the
                          widest each branch reaches, because --t-cmd scales
                          with the viewport: at the top of the stacked range the
                          log is 7px taller than at the bottom of it. Both
                          already count the label and its margin.
             the header   --header-h, which is 72px on one row, 84px on the two
                          it wraps to below 62rem, and 102px on a coarse pointer
                          below that width, where the theme control grows to 44
                          and takes the row with it. That third value applies to
                          exactly the devices the two coarse branches below were
                          written for: 722 + 102 + 48 = 872 against a 1008px
                          release, and 573 + 102 + 48 = 723 against 912. Both
                          clear, and neither is close. It is sticky at zero and
                          opaque, so it is not headroom — it is subtracted.
                          84 and not the 78 that stood here before the theme
                          control existed: the control has to be 24px square to
                          clear WCAG 2.2 SC 2.5.8, the two-row header had one
                          pixel of slack at the width where --t-small peaks,
                          and the row grew. The one-row header did not move,
                          because min-height already holds it at 72 and the
                          control fits inside that untouched.
             the drift    48px, not 24. The log starts the range 24px low and
                          ends it 24px high, and the content is centred, so the
                          band has to hold both ends.

           621 + 72 + 48 = 741, and 723 + 84 + 48 = 855.

           The stacked branch releases at 864px, so it starts pinning at 865,
           and 865 - 855 leaves 10px. Count it that way and not as 864 - 855:
           the guard is a max-height, so the first height that has to hold the
           requirement is one past it — the same convention the note below uses
           when it says 753 is the shortest viewport that still pins. The
           header took 6 of those pixels. It is a thinner margin than this
           carried before and worth saying rather than burying.

           Then the *unit*. On a phone the address bar does not shrink the
           initial containing block, so `100vh` and the height a media query
           reports are both the **large** viewport, while the area on screen
           with the toolbars showing is the **small** one, some 100-140px less.
           A guard written against the large viewport keeps the pin on exactly
           the devices whose visible area is smallest. So the stage floor is
           `100svh`, which is the small viewport by definition, and the toolbar
           band is carried as an allowance on the coarse-pointer branch.

           47rem / 54rem where the viewport is what it claims, 57rem / 63rem
           where it is not. The split is on pointer rather than one conservative
           number because a 900px-tall laptop still clears the requirement and
           would otherwise lose the scene to a phone's problem. A 1366x768 laptop
           loses it anyway — 640px of viewport cannot hold 741 — and that is the
           honest answer rather than a failure: it gets the ledger, complete and
           still.

           47rem and not 48. 741px is 46.31rem, so 48rem was a rounding, and the
           rounding had a cost: it let go of every viewport from 753 to 768,
           all of which fit. That band is not hypothetical — a 900px panel with
           a taskbar and browser chrome taken off lands in it, and it was losing
           a scene it had room for.

           47rem is not the requirement either, and the 12px it keeps over 741
           was not chosen: 47 is simply the smallest whole rem that clears the
           requirement, and the grid left the residue. What the residue buys is
           still worth stating, because it is checkable: the band may grow to
           633px before the last row crosses the fold at 753, and to 645px at
           765. So a monospace fallback with heavier metrics on some other
           machine has room to be heavier, but only that much.

           The guard stays in rem rather than px on purpose. Every term of the
           requirement is rem-derived — --header-h is --rhythm times three above
           62rem and times three and a half below it, the
           drift is --rhythm, and --t-cmd's clamp ceiling governs the band at
           the widths where it is largest — and a media-query rem tracks the
           reader's own default font size. A guard written in px would come
           loose from a band that scales.

           Measured at the new edge rather than reasoned about: 753px is the
           shortest viewport that still pins, 752 the tallest that lets go, and
           at 753 the low end of the drift leaves the last row 6px inside the
           fold. Across widths the wide case is the worst one — the band shrinks
           with --t-cmd faster than the header grows below 62rem — so the sweep
           that mattered is the one at 1350 and up. */
        @media (max-height: 47rem),
               (max-width: 34rem) and (max-height: 54rem),
               (pointer: coarse) and (max-height: 57rem),
               (pointer: coarse) and (max-width: 34rem) and (max-height: 63rem) {
          .scene {
            height: auto;
            padding-block: var(--space-7);
            view-timeline: none;
          }

          .scene__stage {
            position: static;
            min-height: 0;
          }

          .log,
          .log dt,
          .log dd { animation: none; }
        }
      }

      /* A progress ruler on scroll(root block) lived here and has been removed.
         It worked, and it was the one thing on the page anyone noticed — as
         anxiety. A progress bar answers "how much is left", which is a question
         a reader of a long article wants and a reader of a personal page does
         not; it turns a page you are looking at into a task you are getting
         through. Built to spec, wrong for the page, and the reader's reaction
         outranks the spec.

         Its removal takes the only scroll() call with it, so the @supports
         condition that guarded it went too. The two that remain — view() for
         the rules, the headings and the figures, and a named view-timeline for
         the scene — each guard the feature their own mechanism calls. */
    }
  }
}


/* Paper. There is no PDF to download, so this is the version that gets printed
   and read on a train, and it is a deliverable rather than a courtesy.

   It is a different medium, not a narrower screen. The reader cannot scroll,
   cannot hover and cannot follow a link, so the sticky header and the nav go,
   the sections stop being a grid and become one column, and every link says
   where it points. The accent stops being a flood: a page of vermilion is a
   cartridge someone else is paying for, and the hierarchy it was carrying is
   already carried by size and weight. Less of a saving than it was — the
   accent stopped being a flood on screen too — but the section rule that
   reaches the sheet drops to black rather than printing a colour nobody asked
   for. One of the two: the hero's comes off below, and the note there says
   why.

   Everything here is a token change plus a handful of layout resets, which is
   the whole argument for having put the values in :root.

   Deliberately outside every @layer. Unlayered declarations beat layered ones
   whatever their specificity, and the tokens at the top of this file are
   unlayered — so a print :root written inside a layer loses to the screen
   palette and prints the vermilion anyway. It did, once. */

@media print {
    /* One selector, and it can be one because the dark palette is guarded with
       `screen and` rather than left to lose a source-order tie to this block.
       See the note above it: the hazard was removed instead of documented.

       color-scheme is reset here for the same reason, and it is worth keeping
       even though :root now says `light` rather than `light dark`. It reaches
       paper either way, body drops its background below, and the sheet's own
       surface would be left to whatever the UA was last told. Stating it makes
       this block self-contained instead of depending on a value declared eight
       hundred lines away that a future edit could widen back. */
    :root {
      color-scheme: light;

      --canvas:        #fff;
      --canvas-raised: #fff;

      --ink:       #000;
      --ink-muted: #333;
      --ink-quiet: #444;

      --rule:            #bbb;
      --rule-strong:     #888;

      /* The accent survives as ink, not as colour: the bullet marks, the dates
         and the section rule that opens the argument keep their shape and lose
         their hue. */
      --accent:      #000;

      /* The hero's fill goes white and its ink goes black, which inverts the
         section on paper rather than printing it. That is the right way round:
         a background is only painted if the reader ticked "Background
         graphics", so a fill that survived here would appear for some readers
         and not others, and the ink written for it would be bone on white for
         everyone else. Declaring both ends keeps the sheet legible whichever
         way that setting is set. */
      --surface:     #fff;
      --surface-ink: #000;

      /* Pinned rather than left to derive, and the derivation is why. On screen
         this is 68% of --surface-ink over the fill; on paper --surface-ink is
         black and the fill is white, so the same expression lands on roughly
         #525252 — 7.81:1, which would make the hairline over the location the
         heaviest rule on the sheet while the section rules were deliberately
         lightened to #bbb at 1.92. A token that is a relationship on screen has
         to become a value here, because the two ends it relates were both
         flattened. #888 is 3.54 and matches the weight this line had on paper
         before the fill came back. */
      --rule-on-surface: #888;

      /* Transparent rather than a grey, and for the same reason the section
         rule is a border: a background is only painted if the reader has
         ticked "Background graphics", so a tint here is a chip that appears
         for some readers and not others. The mono face already separates a
         code token from the sentence around it, which is the whole job on
         paper. Setting #eee would be asserting something the medium does not
         promise to honour. */
      --code-tint: transparent;

      /* Fixed point sizes rather than the fluid scale. The clamp() steps are
         driven by vw, and a viewport width is not a thing paper has. */
      --t-micro:   7.5pt;
      --t-small:   8.5pt;
      --t-body:   10.0pt;
      --t-lead:   11.5pt;
      --t-h3:     12.0pt;
      --t-h2:     14.0pt;
      --t-figure: 15.0pt;
      --t-display:26.0pt;

      --leading-body: 1.42;

      --rhythm:  8pt;
      --measure: none;   /* the page margin is the measure now */

      /* Two across rather than three. At three the fourth figure falls to a
         row of its own with two empty cells beside it. */
      --figure-min: 200pt;
    }

    @page { margin: 15mm 14mm; }

    html { font-size: 10pt; }

    body {
      background: none;
      color: var(--ink);
    }

    /* One column. Dropping the grid retires every grid-column rule in the file
       at once, which is cheaper and safer than overriding them one by one. */
    main > section,
    .site-footer {
      display: block;
      padding-block: var(--space-4);
    }

    /* The section rules were darkened on screen so the one mechanism with real
       duration would be visible enough to be worth the duration. Paper has no
       duration, so the argument does not travel and neither should the value:
       back to the lighter hairline the printed sheet was designed and checked
       with. */
    main > section::before { border-block-start-color: var(--rule); }

    /* Nothing that only means something to a cursor. */
    .skip-link,
    .site-header { display: none; }

    /* The scene is a screen device: it argues by unfolding, and paper does not
       unfold. Everything it says is said again in prose inside the AI-first
       section, so nothing is lost by dropping it. */
    .scene { display: none; }

    /* The figures band is its own edge on screen because it is a colour block;
       on paper --canvas-raised is white, so it has no edge at all. It used to
       sit straight after the hero and borrow the hero's own bottom rule. It
       does not any more, and without this the About paragraphs run into the
       numbers with nothing between them while every other boundary on the sheet
       carries a hairline. */
    .figures { border-block-start: var(--hairline) solid var(--rule); }

    /* Paper reads in DOM order, and there was briefly a version here that did
       not. It put the figures second and the practice below the person, on the
       argument that a screen is read and a CV is scanned — done with `order` on
       a flex column, so the reading order for a screen reader stayed the DOM's.

       It came out again. Turning `main` into a flex container makes the whole
       four-page sheet one fragmented flex box, and fragmenting a flex container
       across pages is the part of the print model engines disagree about most;
       Chromium does it, WebKit has a long history of clipping everything past
       the first sheet. Headless --print-to-pdf here is Chromium, so the rig
       that would have caught it is the one engine that gets it right, and there
       is no Safari on this machine to check against. An unverifiable mechanism
       whose failure mode is "the CV prints page one" is a bad trade for putting
       the numbers three places higher. The reorder can come back the day it can
       be tested on the engine that would break it. */

    /* Padding only. There is no border to reset here because the screen rule
       does not draw one — the hero's division is its fill, and the token block
       above sends --surface to white, because a background is only painted if
       the reader ticked "Background graphics" and a band that survived to
       paper would appear for some readers and not others.

       Worth knowing why nothing is put back in its place. The scene is
       display:none here, so the hero and #ai-first become adjacent, and
       #ai-first already opens on 3px of accent turned black. An edge under the
       hero would land a dozen points from that rule with nothing in between:
       measured, not guessed — printed with a border on both, page one carries
       a black bar, a gap, and a second black bar. The division the name block
       needs on paper is the space above the heading below it. */
    .hero {
      padding-block: 0 var(--space-4);
    }

    .hero h1 { margin-block-end: var(--space-2); }
    .hero__place { margin-block-start: var(--space-3); }

    .figures { padding-block: var(--space-3); }
    .figures ul { gap: var(--space-3); }

    /* The band was full-bleed by way of a padding that pushed its text back to
       the content column. With no band left, that padding is just an indent. */
    #ai-first > h2,
    #ai-first > .lede {
      padding-inline: 0;
      padding-block: 0;
    }

    #ai-first { padding-block-start: var(--space-4); }
    #ai-first > .lede { margin-block-start: var(--space-1); }
    #ai-first > h3 { margin-block-start: var(--space-4); }

    h2 { margin-block-end: var(--space-2); }

    /* Where the page may and may not break. A heading at the foot of a sheet
       with its paragraph overleaf is the classic printed-page defect. */
    h1, h2, h3 { break-after: avoid; }

    .role,
    .figures li,
    .education li,
    .role li { break-inside: avoid; }

    p { orphans: 3; widows: 3; }

    /* A link on paper is a dead end unless it says where it goes. The nav is
       gone by now, so what is left is the three that matter: LinkedIn, GitHub,
       and the sandbox this page cites. The colophon is excluded because its
       link text is already the address — printing it twice reads as a defect,
       which is exactly what it looked like the first time round. */
    main a[href^="http"]::after,
    .site-footer__contact a[href^="http"]::after {
      content: " (" attr(href) ")";
      font-size: 0.85em;
      font-weight: 400;
      color: var(--ink-muted);
      overflow-wrap: anywhere;
    }

    /* Lead size is for a screen, where the footer is the last thing on a long
       scroll. On paper it is a footer. */
    .site-footer__contact { font-size: var(--t-body); }
}
