/* Custom styles for williamdemeo.github.io
 *
 * Everything here is expressed in the tokens from tokens.css; there are no raw
 * colours or sizes below this comment.  That is the property that makes the
 * visual system a system rather than a set of preferences: changing one token
 * moves every place it is used, and nothing else can drift.
 *
 * Material is restyled through its own custom properties rather than by
 * overriding its rules, which is the supported route and survives a theme
 * upgrade.  Where a rule is unavoidable it is because Material hard-codes a
 * value that has no variable.
 *
 * Load order matters and is set in mkdocs.yml: fonts.css declares the faces,
 * tokens.css declares the values, this file applies them.
 */

/* ── Material's variables, expressed in ours ─────────────────────────────────
 *
 * The selector is `[data-md-color-scheme]` rather than `:root` because that
 * attribute lives on <body>, which is also where tokens.css puts the colour
 * tokens.  Mapping on :root would read them from an element that does not
 * have them.
 */

[data-md-color-scheme] {
  /* JuliaMono is the last self-hosted entry in the text stack, not an
     afterthought: no text face carries ∀, ⊢ or ⨅, and a mathematical symbol
     that reaches prose should come from a font this site ships rather than
     from whatever the reader's machine offers.  Material appends its own
     system fallbacks after whatever this names. */
  --md-text-font: var(--font-body), var(--font-mono);
  --md-code-font: var(--font-mono);

  --md-default-bg-color: var(--c-bg);
  --md-default-bg-color--light: color-mix(in srgb, var(--c-bg) 70%, transparent);
  --md-default-bg-color--lighter: color-mix(in srgb, var(--c-bg) 30%, transparent);
  --md-default-bg-color--lightest: color-mix(in srgb, var(--c-bg) 12%, transparent);

  --md-default-fg-color: var(--c-fg);
  --md-default-fg-color--light: var(--c-fg-muted);
  --md-default-fg-color--lighter: var(--c-fg-faint);
  --md-default-fg-color--lightest: var(--c-line);

  --md-typeset-color: var(--c-fg);
  --md-typeset-a-color: var(--c-accent);
  --md-typeset-mark-color: var(--c-accent-wash);
  --md-typeset-del-color: color-mix(in srgb, var(--c-accent) 18%, transparent);
  --md-typeset-ins-color: color-mix(in srgb, var(--c-accent) 18%, transparent);
  --md-typeset-table-color: var(--c-line);
  --md-typeset-kbd-color: var(--c-bg-raised);
  --md-typeset-kbd-border-color: var(--c-line-strong);
  --md-typeset-kbd-accent-color: var(--c-bg);

  /* The header is paper with a hairline under it, not a block of colour.  That
     is most of what separates a 2026 documentation site from a 2016 one. */
  --md-primary-fg-color: var(--c-bg);
  --md-primary-fg-color--light: var(--c-bg-raised);
  --md-primary-fg-color--dark: var(--c-bg-sunken);
  --md-primary-bg-color: var(--c-fg);
  --md-primary-bg-color--light: var(--c-fg-muted);

  --md-accent-fg-color: var(--c-accent-hover);
  --md-accent-fg-color--transparent: var(--c-accent-wash);
  --md-accent-bg-color: var(--c-on-accent);
  --md-accent-bg-color--light: var(--c-on-accent);

  --md-code-bg-color: var(--c-bg-raised);
  --md-code-fg-color: var(--c-fg);

  --md-footer-bg-color: var(--c-bg-sunken);
  --md-footer-bg-color--dark: var(--c-bg-sunken);
  --md-footer-fg-color: var(--c-fg);
  --md-footer-fg-color--light: var(--c-fg-muted);
  /* Material puts the "Made with ..." line in --lighter.  The faint token is
     tuned to clear AA on the page surfaces, not on the darker footer band, so
     the footer gets the muted one instead of a fourth grey. */
  --md-footer-fg-color--lighter: var(--c-fg-muted);

  /* No drop shadows anywhere.  Depth is carried by a one-pixel line and a
     change of surface, which reads the same in both themes and does not need
     a different colour for each. */
  --md-shadow-z1: 0 0 0 1px var(--c-line);
  --md-shadow-z2: 0 0 0 1px var(--c-line);
  --md-shadow-z3: 0 0 0 1px var(--c-line-strong);
}

/* Syntax highlighting.  Material's defaults are tuned to its own palettes and
 * lose contrast against these surfaces, so the eight token colours are set
 * from the palette instead.  Keywords and strings carry the hue; everything
 * else is a grey, because a code block where nine things are coloured is a
 * code block where nothing stands out.  Ratios are checked by
 * `make contrast-audit`, which reads the rendered page rather than this file.
 */

[data-md-color-scheme="default"] {
  --md-code-hl-color: var(--c-accent-wash);
  --md-code-hl-color--light: var(--c-accent-wash);
  --md-code-hl-keyword-color: #7c3aed;
  --md-code-hl-function-color: #1d4ed8;
  --md-code-hl-string-color: #15803d;
  --md-code-hl-number-color: #b45309;
  --md-code-hl-constant-color: #b45309;
  --md-code-hl-special-color: #be123c;
  --md-code-hl-operator-color: var(--c-fg-muted);
  --md-code-hl-punctuation-color: var(--c-fg-muted);
  --md-code-hl-name-color: var(--c-fg);
  --md-code-hl-variable-color: var(--c-fg);
  --md-code-hl-comment-color: var(--c-fg-faint);
  --md-code-hl-generic-color: var(--c-fg-muted);
  /* The typed-proof terminal's verdict green (M3-2d, #96), worn by the ✓
     line and the goal pill at zero.  The pair is the 404 typed-comment's,
     measured there (#98): dark carries the palette's own string green;
     in light that token runs thin on these surfaces, so light gets the
     same deeper leaf of it, 6.0:1 on the raised surface. */
  --proof-ok: #136b34;
}

[data-md-color-scheme="slate"] {
  --md-code-hl-color: var(--c-accent-wash);
  --md-code-hl-color--light: var(--c-accent-wash);
  --md-code-hl-keyword-color: #c4b5fd;
  --md-code-hl-function-color: #93c5fd;
  --md-code-hl-string-color: #86efac;
  --md-code-hl-number-color: #fcd34d;
  --md-code-hl-constant-color: #fcd34d;
  --md-code-hl-special-color: #fda4af;
  --md-code-hl-operator-color: var(--c-fg-muted);
  --md-code-hl-punctuation-color: var(--c-fg-muted);
  --md-code-hl-name-color: var(--c-fg);
  --md-code-hl-variable-color: var(--c-fg);
  --md-code-hl-comment-color: var(--c-fg-faint);
  --md-code-hl-generic-color: var(--c-fg-muted);
  --proof-ok: #86efac;
}

/* ── typography ─────────────────────────────────────────────────────────── */

.md-typeset {
  font-size: var(--type-base);
  line-height: var(--leading-body);
  letter-spacing: var(--tracking-body);
}

.md-typeset h1,
.md-typeset h2,
.md-typeset h3,
.md-typeset h4 {
  font-family: var(--font-display), var(--font-body), var(--font-mono), sans-serif;
  color: var(--c-fg);
  letter-spacing: var(--tracking-display);
  line-height: var(--leading-heading);
  text-wrap: balance;
}

.md-typeset h1 {
  font-size: var(--type-h1);
  font-weight: var(--display-weight);
  margin: 0 0 var(--space-8);
}

.md-typeset h2 {
  font-size: var(--type-h2);
  font-weight: var(--display-weight);
  margin: var(--space-16) 0 var(--space-4);
  padding-bottom: var(--space-2);
  border-bottom: var(--border-width) solid var(--c-line);
}

.md-typeset h3 {
  font-size: var(--type-h3);
  font-weight: var(--strong-weight);
  margin: var(--space-12) 0 var(--space-3);
}

.md-typeset h4 {
  font-size: var(--type-h4);
  font-weight: var(--strong-weight);
  letter-spacing: var(--tracking-body);
  margin: var(--space-8) 0 var(--space-2);
}

.md-typeset h5 {
  font-size: var(--type-small);
  font-weight: var(--strong-weight);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--c-fg-muted);
}

.md-typeset strong,
.md-typeset b {
  font-weight: var(--strong-weight);
}

/* Line length.  Only the prose blocks are capped, and only where they are
 * direct children of the article: a paragraph inside a table cell or an
 * admonition already has a container deciding its width. */
.md-typeset > p,
.md-typeset > ul,
.md-typeset > ol,
.md-typeset > dl,
.md-typeset > blockquote {
  max-width: var(--measure);
}

.md-typeset > h1,
.md-typeset > h2,
.md-typeset > h3,
.md-typeset > h4 {
  max-width: var(--measure-heading);
}

.md-typeset p {
  margin: 0 0 var(--space-6);
}

.md-typeset blockquote {
  border-left: 2px solid var(--c-line-strong);
  color: var(--c-fg-muted);
  padding-left: var(--space-4);
}

.md-typeset hr {
  border-bottom: var(--border-width) solid var(--c-line);
  margin: var(--space-16) 0;
}

/* Links: coloured, and underlined only on hover.  A page of prose with a
 * permanent underline under every citation is noisier than it is helpful, but
 * colour alone is not a sufficient affordance, so the underline appears on
 * hover and focus and the colour clears AA on its own. */
.md-typeset a {
  text-decoration: none;
  border-bottom: var(--border-width) solid color-mix(in srgb, var(--c-accent) 35%, transparent);
  transition: border-color 120ms, color 120ms;
}

.md-typeset a:hover,
.md-typeset a:focus-visible {
  color: var(--c-accent-hover);
  border-bottom-color: var(--c-accent);
}

/* Headings own their permalinks; the anchor should not read as body link. */
.md-typeset .headerlink {
  border-bottom: none;
}

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

/* ── code ───────────────────────────────────────────────────────────────── */

.md-typeset code,
.md-typeset pre > code,
.md-typeset kbd {
  font-family: var(--font-mono), monospace;
  font-size: var(--type-code);
  /* Only JuliaMono Regular is shipped -- the syntax theme is colour-only, so
     no bold or italic code face is ever needed.  Pinning the weight stops a
     `code` inside a heading asking for 600 and getting a synthesised bold. */
  font-weight: 400;
  font-variant-ligatures: none;
}

.md-typeset code {
  background-color: var(--c-bg-raised);
  border-radius: var(--radius-sm);
  padding: 0.1em 0.3em;
}

.md-typeset pre > code {
  padding: var(--space-4);
  line-height: 1.6;
}

.md-typeset .highlight,
.md-typeset .highlighttable {
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-md);
  overflow: hidden;
}

.md-typeset .highlight code {
  background: transparent;
}

/* ── mathematics ────────────────────────────────────────────────────────────
 *
 * KaTeX ships `font-size: 1.21em` on `.katex` to compensate for Computer
 * Modern's small x-height.  1.21 is right for the body face KaTeX was designed
 * against, not for the one this site uses; --math-scale is the measured ratio
 * for the active system.  See tokens.css.
 */

.katex {
  font-size: var(--math-scale);
}

.md-typeset .katex-display {
  margin: var(--space-8) 0;
  overflow-x: auto;
  overflow-y: hidden;
  padding: var(--space-1) 0;
}

/* ── chrome ─────────────────────────────────────────────────────────────── */

.md-header {
  box-shadow: none;
  border-bottom: var(--border-width) solid var(--c-line);
}

.md-header--shadow {
  box-shadow: none;
}

.md-tabs {
  border-bottom: var(--border-width) solid var(--c-line);
}

.md-nav {
  font-size: var(--type-ui);
}

.md-nav__title {
  color: var(--c-fg-muted);
  font-weight: var(--strong-weight);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  font-size: var(--type-ui-small);
}

/* Search.  Material builds the field out of hsla(0,0%,100%,.12) over the
 * primary colour, which assumes the header is a block of colour.  Against
 * paper it is a grey slab, so the field is restated as a bordered surface. */
.md-search__form {
  background-color: var(--c-bg-raised);
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-md);
  box-shadow: none;
}

.md-search__form:hover {
  background-color: var(--c-bg-sunken);
}

.md-search__input,
.md-search__icon {
  color: var(--c-fg);
}

.md-search__input::placeholder {
  color: var(--c-fg-muted);
}

[data-md-toggle="search"]:checked ~ .md-header .md-search__form {
  background-color: var(--c-bg);
  border-color: var(--c-line-strong);
}

.md-search__output {
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-md);
  box-shadow: none;
}

.md-footer-meta {
  border-top: var(--border-width) solid var(--c-line);
}

.md-typeset table:not([class]) {
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-md);
  font-size: var(--type-small);
}

.md-typeset table:not([class]) th {
  background-color: var(--c-bg-raised);
  font-weight: var(--strong-weight);
}

/* Material renders the search overlay and the admonition titles with a tint
 * derived from the accent; keep those on the palette too. */
.md-typeset .admonition,
.md-typeset details {
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-md);
  box-shadow: none;
  font-size: var(--type-small);
}

.md-typeset .md-button {
  /* Material builds its buttons out of --md-primary-fg-color, which here is
     the paper colour, so the default rule is white on white.  They are
     restated from the palette rather than patched. */
  color: var(--c-fg);
  border: var(--border-width) solid var(--c-line-strong);
  border-radius: var(--radius-md);
  font-weight: var(--strong-weight);
  padding: var(--space-2) var(--space-4);
  transition: background-color 120ms, color 120ms, border-color 120ms;
}

.md-typeset .md-button--primary,
.md-typeset .md-button:hover,
.md-typeset .md-button:focus-visible {
  background-color: var(--c-accent);
  border-color: var(--c-accent);
  color: var(--c-on-accent);
}

/* Admonitions.  Material gives each of its dozen types a hard-coded hue, which
 * is a lot of colour for a page that is meant to have one.  The informational
 * types are folded onto the accent; the ones that mean "be careful" keep their
 * semantic colour, because that is what they are for. */
.md-typeset .admonition.note,
.md-typeset .admonition.info,
.md-typeset .admonition.abstract,
.md-typeset .admonition.tip,
.md-typeset .admonition.example,
.md-typeset .admonition.quote,
.md-typeset details.note,
.md-typeset details.info,
.md-typeset details.abstract,
.md-typeset details.tip,
.md-typeset details.example,
.md-typeset details.quote {
  border-color: var(--c-line);
  border-left: 2px solid var(--c-accent);
}

.md-typeset .note > .admonition-title,
.md-typeset .info > .admonition-title,
.md-typeset .abstract > .admonition-title,
.md-typeset .tip > .admonition-title,
.md-typeset .example > .admonition-title,
.md-typeset .quote > .admonition-title,
.md-typeset .note > summary,
.md-typeset .info > summary,
.md-typeset .abstract > summary,
.md-typeset .tip > summary,
.md-typeset .example > summary,
.md-typeset .quote > summary {
  background-color: var(--c-accent-wash);
  color: var(--c-fg);
}

.md-typeset .note > .admonition-title::before,
.md-typeset .info > .admonition-title::before,
.md-typeset .abstract > .admonition-title::before,
.md-typeset .tip > .admonition-title::before,
.md-typeset .example > .admonition-title::before,
.md-typeset .quote > .admonition-title::before,
.md-typeset .note > summary::before,
.md-typeset .info > summary::before,
.md-typeset .abstract > summary::before,
.md-typeset .tip > summary::before,
.md-typeset .example > summary::before,
.md-typeset .quote > summary::before {
  background-color: var(--c-accent);
}

/* ── home hero (M3-2a, #93) ──────────────────────────────────────────────────
 *
 *   <div class="hero" markdown>
 *   <div class="hero-copy" markdown>
 *   eyebrow line {.hero-eyebrow}
 *   # Display claim
 *   One-paragraph claim. {.hero-claim}
 *   [action](…){.md-button .md-button--primary} [action](…){.md-button}
 *   {.hero-actions}
 *   </div>
 *   <div class="hero-side">
 *   <!-- proof-terminal -->
 *   </div>
 *   </div>
 *
 * A grid, not a float.  The second column holds the typed-proof terminal
 * (M3-2d, #96) over the constellation backdrop (M3-2b, #94); the
 * min-block-size holds the stage at the height the portrait used to
 * establish, so a column shorter than the copy reads as canvas rather than
 * collapse, and `align-items: center` seats both columns in it.
 */

.md-typeset .hero {
  display: grid;
  grid-template-columns: 1.15fr 0.85fr;
  gap: var(--space-12);
  align-items: center;
  margin: var(--space-8) 0;
  min-block-size: var(--hero-stage);
}

/* Grid items refuse to shrink below their content's min-width by default;
 * without this a long code line in the second column (M3-2d) would widen the
 * page rather than scroll. */
.md-typeset .hero > * {
  min-width: 0;
}

.md-typeset .hero-eyebrow {
  font-size: var(--type-small);
  font-weight: var(--strong-weight);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--c-fg-muted);
  margin-bottom: var(--space-2);
}

/* The h1 keeps its type tokens; only its bottom margin tightens, because the
 * hero's vertical rhythm is the grid's business. */
.md-typeset .hero h1 {
  margin-bottom: var(--space-4);
}

.md-typeset .hero-claim {
  color: var(--c-fg-muted);
  max-width: var(--measure);
}

.md-typeset .hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-bottom: 0;
}

/* Material's mobile breakpoint, same value the timeline uses.  Stacked, the
 * stage is not held open: a phone screen has no second column to keep clear,
 * so the min-block-size returns to auto and the copy sets the height. */
@media screen and (max-width: 44.9375em) {
  .md-typeset .hero {
    grid-template-columns: 1fr;
    gap: var(--space-6);
    min-block-size: auto;
  }
}

/* ── constellation (M3-2b, #94) ──────────────────────────────────────────────
 *
 * Hasse diagrams drawn as star charts: the site's identity mark, and the
 * first component governed by ADR-009 (motion).  The markup is the inline SVG
 * in docs/_snippets/hero-constellation.html; each line and star carries a
 * stagger index `--i`, and that is the only styling the markup holds.
 *
 * The base rules below are the FINAL state -- lines drawn, stars lit, labels
 * legible.  Motion exists only inside the `no-preference` media query, so
 * reduced-motion readers, crawlers and JS-off readers all get the finished
 * drawing by construction rather than by a fallback path.  No JavaScript
 * anywhere in this component.
 *
 * Lines carry pathLength="1", which is what lets one dasharray value draw
 * every line to exactly its own length -- without it, dasharray is measured
 * in user units and any line longer than the value shows a gap in its final
 * state.
 *
 * The labels are real text (JuliaMono; ₅, ₃ and ₇ are in the symbols subset,
 * 𝟚 in mathalpha) at full opacity: the contrast audit folds element opacity
 * into the foreground colour, so a faded label would fail AA in a way a
 * faded circle cannot.  Their font-size is a bare 11px on purpose: inside
 * the SVG that is a length in viewBox user units -- geometry, like `r` and
 * `stroke-width`, scaled with the drawing by the viewport transform -- not a
 * step on the type scale.  A --type-* token (em/rem) would resolve against
 * the page's font size and then be rescaled by the same transform, giving a
 * size that tracks neither the type system nor the drawing.
 */

.md-typeset .constellation line {
  stroke: var(--c-line-strong);
  stroke-width: 1;
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
}

.md-typeset .constellation .star {
  fill: var(--c-accent);
  opacity: 0.95;
}

.md-typeset .constellation .halo {
  fill: var(--c-accent);
  opacity: 0.14;
}

.md-typeset .constellation text {
  fill: var(--c-fg-faint);
  font-family: var(--font-mono), monospace;
  font-size: 11px;
}

@media (prefers-reduced-motion: no-preference) {
  .md-typeset .constellation line {
    stroke-dashoffset: 1;
    animation: constellation-draw var(--motion-draw) var(--motion-ease) forwards;
    animation-delay: calc(var(--i) * var(--motion-stagger));
  }

  .md-typeset .constellation .star {
    opacity: 0;
    animation: constellation-pop var(--motion-pop) var(--motion-ease) forwards;
    animation-delay: calc(var(--i) * var(--motion-stagger));
  }

  .md-typeset .constellation .halo {
    opacity: 0;
    animation: constellation-haze var(--motion-pop) var(--motion-ease) forwards;
    animation-delay: calc(var(--i) * var(--motion-stagger) + var(--motion-halo-lag));
  }

  /* A few stars keep shimmering after the draw-in.  The second delay holds
     the twinkle back until the drawing has finished; the token records the
     derivation. */
  .md-typeset .constellation .star.tw {
    animation:
      constellation-pop var(--motion-pop) var(--motion-ease) forwards,
      constellation-twinkle var(--motion-twinkle) ease-in-out infinite;
    animation-delay: calc(var(--i) * var(--motion-stagger)), var(--motion-twinkle-at);
  }

  .md-typeset .constellation text {
    opacity: 0;
    animation: constellation-label var(--motion-label) var(--motion-ease) forwards;
    animation-delay: var(--motion-labels-at);
  }
}

@keyframes constellation-draw { to { stroke-dashoffset: 0; } }
@keyframes constellation-pop { to { opacity: 0.95; } }
@keyframes constellation-haze { to { opacity: 0.14; } }
@keyframes constellation-label { to { opacity: 1; } }
@keyframes constellation-twinkle {
  0%, 100% { opacity: 0.95; }
  50% { opacity: 0.35; }
}

/* In the hero the constellation is the background layer: absolutely placed
 * under the two grid columns, cropped by `slice`, and inert to the pointer.
 * The rules target the snippet's wrapper div, because the wrapper is what
 * Markdown guarantees arrives intact (see the comment in docs/index.md). */
.md-typeset .hero {
  position: relative;
}

.md-typeset .hero > .constellation-wrap {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.md-typeset .constellation-wrap .constellation {
  display: block;
  width: 100%;
  height: 100%;
}

.md-typeset .hero > .hero-copy,
.md-typeset .hero > .hero-side {
  position: relative;
  z-index: 1;
}

/* Below the breakpoint the columns stack over the drawing, so it recedes:
 * the whole layer dims and the labels leave rather than fail contrast under
 * an ancestor opacity the audit would fold in. */
@media screen and (max-width: 44.9375em) {
  .md-typeset .hero > .constellation-wrap {
    opacity: 0.35;
  }

  .md-typeset .hero > .constellation-wrap text {
    display: none;
  }
}

/* In the hero the constellation draws itself in and then RESTS: the
 * draw-in is the overture to the typed-proof replay, not a competitor.
 * M3-2d's first cut froze the whole drawing here (ADR-009's amendment of
 * 2026-08-04: two orchestrated timelines in one viewport), but the
 * terminal's entrance delay changed the facts that ruling stood on -- the
 * frame now holds back for --motion-hero-enter while the lattices draw,
 * and the two signatures are sequenced rather than simultaneous; see the
 * ADR's second amendment.  What still yields is the idle twinkle: a
 * shimmer with no end beside a replayable demonstration is two timelines
 * the moment the replay button is pressed, so in the hero the twinkling
 * stars keep their pop and lose the loop.  Equal specificity, later in
 * the file, is what lets this single rule beat the base `.star.tw` rule;
 * the style page's demo and the 404 keep the full behaviour because their
 * rules target `.constellation` and this targets the hero's
 * `.constellation-wrap`. */
@media (prefers-reduced-motion: no-preference) {
  .md-typeset .constellation-wrap .star.tw {
    animation: constellation-pop var(--motion-pop) var(--motion-ease) forwards;
    animation-delay: calc(var(--i) * var(--motion-stagger));
  }
}

/* On the style page the component sits in a framed box at its natural
 * aspect rather than behind content. */
.md-typeset .constellation-demo {
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-md);
  background-color: var(--c-bg-sunken);
  margin: var(--space-8) 0;
}

.md-typeset .constellation-demo .constellation {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1000 / 420;
}

/* ── evidence strip (M3-2c, #95) ─────────────────────────────────────────────
 *
 * Rendered by scripts/python/evidence_hook.py from docs/assets/evidence.json;
 * the markup never appears in a source file.  Cells are links -- each figure
 * goes to the thing that can check it -- separated by hairlines made of the
 * grid's gap over a line-coloured background, which is this site's depth
 * idiom (ADR-005: no shadows).
 *
 * The count-up in evidence.js rewrites .ev-n's text; everything here styles
 * the resting state, which is also the reduced-motion and no-JS state.
 */

.md-typeset .evidence {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
  gap: var(--border-width);
  background-color: var(--c-line);
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-md);
  overflow: hidden;
  margin: var(--space-6) 0 var(--space-3);
}

.md-typeset .evidence .ev {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  background-color: var(--c-bg-raised);
  padding: var(--space-4) var(--space-4) var(--space-3);
  border-bottom: none;
  transition: background-color 120ms;
}

.md-typeset .evidence .ev:hover,
.md-typeset .evidence .ev:focus-visible {
  background-color: var(--c-accent-wash);
}

.md-typeset .evidence .ev-n {
  font-family: var(--font-display), var(--font-body), sans-serif;
  font-size: var(--type-h2);
  font-weight: var(--display-weight);
  letter-spacing: var(--tracking-display);
  line-height: var(--leading-heading);
  font-variant-numeric: tabular-nums;
  color: var(--c-fg);
}

/* The zeros are the claim -- nothing assumed, nothing fetched -- so they are
 * the one place the strip carries the accent. */
.md-typeset .evidence .ev-n.ev-zero {
  color: var(--c-accent);
}

.md-typeset .evidence .ev-l {
  font-size: var(--type-small);
  font-weight: var(--strong-weight);
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--c-fg-muted);
}

.md-typeset .evidence .ev-d {
  font-size: var(--type-small);
  color: var(--c-fg-muted);
}

.md-typeset .ev-caption {
  font-size: var(--type-small);
  color: var(--c-fg-muted);
  max-width: var(--measure);
}

/* ── typed-proof terminal (M3-2d, #96) ───────────────────────────────────────
 *
 * Rendered by scripts/python/proof_hook.py from docs/assets/proof.json; the
 * markup never appears in a source file, and the sessions it shows -- one
 * per tab, one lemma each -- are ones `make proof` recorded from a real
 * Agda (ADR-009: motion is evidence-honest).  Everything below styles the
 * FINAL state -- completed proof, zero goals, ✓ -- which is also the
 * reduced-motion, JS-off and crawler rendering; proof.js only rewinds it
 * and types it back in.  The tab bar ships `hidden` (proof.js reveals it,
 * like the replay button: no dead controls), so the JS-off rendering is a
 * single finished terminal, the first lemma's.
 *
 * The frame is the site's hairline box, deliberately: #98 tried
 * terminal-window chrome (title bar, dots) on the 404's goal and reverted
 * it.  The bar here earns its place because each of its three occupants is
 * load-bearing -- the source link is provenance, the replay control is
 * ADR-009's keyboard requirement, the goal pill is the state the session is
 * about.  No dots.
 *
 * Colour comes from the code-block tokens: the hook emits Pygments' Agda
 * vocabulary (`c1`, `nf`, `ow` -- the 404's precedent), so the mappings
 * below are the same three every real code block uses; the hole wears the
 * 404 goal's exact recipe (wash, 80/20 ink mix, hairline ring -- the ratios
 * are recorded there); the verdict green is --proof-ok, pinned per scheme
 * beside the syntax colours above.  Nothing here invents a hue.
 */

.md-typeset .proof {
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-md);
  background-color: var(--c-bg-sunken);
  font-family: var(--font-mono), monospace;
  font-size: var(--type-ui);
  font-variant-ligatures: none;
}

/* The terminal holds back while the hero's words land, then fades in and
 * the replay begins (proof.js waits for this animation's end).  CSS-only,
 * so a JS-off reader still gets the frame -- just late; a reduced-motion
 * reader gets it immediately, because the entrance exists only inside this
 * media query.  `visibility` rides along so the held-back frame cannot be
 * clicked or tabbed into before it exists visually. */
@media (prefers-reduced-motion: no-preference) {
  .md-typeset .proof {
    animation: proof-enter var(--motion-check-beat) var(--motion-ease)
      var(--motion-hero-enter) both;
  }
}

@keyframes proof-enter {
  from { opacity: 0; visibility: hidden; }
  to   { opacity: 1; visibility: visible; }
}

/* One tab per lemma, labelled with the definiendum the transcript records.
 * The row needs its own [hidden] guard: `display: flex` would out-rank the
 * user-agent's [hidden] rule, and this bar ships hidden (see above). */
.md-typeset .proof-tabs {
  display: flex;
  flex-wrap: wrap;
  column-gap: var(--space-3);
  row-gap: var(--space-1);
  padding: var(--space-2) var(--space-4);
  border-bottom: var(--border-width) solid var(--c-line);
  font-size: var(--type-ui-small);
}

.md-typeset .proof-tabs[hidden] {
  display: none;
}

/* Real <button>s, like the replay control: Tab reaches the selected one
 * (proof.js keeps the others at tabindex -1, arrow keys walk the row), the
 * global :focus-visible ring applies.  Selection is an accent underline --
 * the site's link grammar -- not a filled chip; the bar stays quiet. */
.md-typeset .proof-tab {
  appearance: none;
  background: none;
  font: inherit;
  color: var(--c-fg-muted);
  border: none;
  border-bottom: var(--border-width) solid transparent;
  padding: 0;
  cursor: pointer;
  transition: color 120ms, border-color 120ms;
}

.md-typeset .proof-tab:hover {
  color: var(--c-accent-hover);
}

.md-typeset .proof-tab[aria-selected="true"] {
  color: var(--c-fg);
  border-bottom-color: var(--c-accent);
}

.md-typeset .proof-bar {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-4);
  border-bottom: var(--border-width) solid var(--c-line);
  font-size: var(--type-ui-small);
  color: var(--c-fg-muted);
}

/* The filename, in the bar's own muted colour: it names what was checked
 * and is not a link (ADR-011 -- the source repository is private).  All it
 * learns here is to give way, so the pill and the control never wrap. */
.md-typeset .proof-src {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  margin-right: auto;
}

/* A real <button>, like the 404's search affordance: Tab reaches it, Enter
 * and Space fire it, the global :focus-visible ring applies.  It ships
 * `hidden` -- proof.js reveals it only when a replay can actually run, so
 * neither a JS-off page nor a reduced-motion one shows a dead control. */
.md-typeset .proof-replay {
  appearance: none;
  background: none;
  font: inherit;
  color: var(--c-fg-muted);
  border: var(--border-width) solid var(--c-line-strong);
  border-radius: var(--radius-sm);
  padding: 0 var(--space-2);
  white-space: nowrap;
  cursor: pointer;
  transition: color 120ms, border-color 120ms;
}

.md-typeset .proof-replay:hover,
.md-typeset .proof-replay:focus-visible {
  color: var(--c-accent-hover);
  border-color: var(--c-accent);
}

.md-typeset .proof-goals {
  border: var(--border-width) solid var(--c-line-strong);
  border-radius: var(--radius-sm);
  padding: 0 var(--space-2);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  transition: color 120ms, border-color 120ms;
}

/* Emptied by the replay's rewind; visibility keeps the box so the bar does
 * not reflow when the count arrives. */
.md-typeset .proof-goals:empty {
  visibility: hidden;
}

.md-typeset .proof-goals-zero {
  color: var(--proof-ok);
  border-color: color-mix(in srgb, var(--proof-ok) 45%, transparent);
}

/* The floor is the tallest session's height -- six code rows (Induction has
 * a base case the others don't) plus the HUD row and its rule -- so
 * switching tabs never moves the page below the terminal.  Shorter sessions
 * carry one row of air instead; a future module taller than six rows would
 * show up immediately as a jump when its tab is chosen. */
.md-typeset .proof-body {
  padding: var(--space-4) var(--space-4) var(--space-3);
  overflow-x: auto;
  min-height: calc(7 * 1em * var(--leading-body)
    + var(--space-3) + var(--space-2) + var(--space-4) + var(--space-3));
}

/* Every line holds its height from birth: the replay empties them all and
 * types back in, and a terminal that grew a row per line would pump the
 * page's layout (the 404's typed comment solves the same problem with a
 * clip; here the row count is the thing that must not change). */
.md-typeset .proof-line {
  white-space: pre;
  line-height: var(--leading-body);
  min-height: calc(1em * var(--leading-body));
}

.md-typeset .proof .c1 { color: var(--md-code-hl-comment-color); }
.md-typeset .proof .nf { color: var(--md-code-hl-function-color); }
.md-typeset .proof .ow { color: var(--md-code-hl-operator-color); }

/* The 404 goal-hole's recipe, by the same numbers: agda2-mode paints an
 * interactive hole green, this site's holes wear the accent, and the 20%
 * lean toward the ink is what clears 5.8:1 on the wash in both themes. */
.md-typeset .proof-hole {
  background-color: var(--c-accent-wash);
  color: color-mix(in srgb, var(--c-accent) 80%, var(--c-fg));
  border-radius: var(--radius-sm);
  box-shadow: 0 0 0 var(--border-width)
    color-mix(in srgb, var(--c-accent) 45%, transparent);
  padding: 0 var(--space-1);
}

.md-typeset .proof-check {
  color: var(--proof-ok);
}

.md-typeset .proof-check .proof-dim {
  color: var(--c-fg-muted);
}

/* The goals HUD: agda-mode's information buffer, one line tall, under a
 * dashed rule so it reads as the tool speaking rather than the file. */
.md-typeset .proof-hud {
  margin-top: var(--space-3);
  padding-top: var(--space-2);
  border-top: var(--border-width) dashed var(--c-line);
  color: var(--c-fg-faint);
  white-space: pre;
  min-height: calc(1em * var(--leading-body));
}

/* The caret exists only while proof.js is typing a line, which it never is
 * under reduced motion; the media query states that rather than assumes it.
 * A drawn box, not a glyph, so no font subset owes it a character. */
@media (prefers-reduced-motion: no-preference) {
  .md-typeset .proof-typing::after {
    content: "";
    display: inline-block;
    width: 0.5em;
    height: 1em;
    vertical-align: text-top;
    background-color: var(--c-accent-hover);
    animation: proof-caret var(--motion-caret) steps(1) infinite;
  }
}

@keyframes proof-caret {
  50% { opacity: 0; }
}

/* Below Material's breakpoint the hero stacks and the terminal takes the
 * full column under the copy; the UI size keeps the session's longest line
 * inside a 390px viewport without a scrollbar (overflow-x above stays as
 * the safety net, per the code-block convention: wrapping Agda is worse). */
@media screen and (max-width: 44.9375em) {
  .md-typeset .proof {
    font-size: var(--type-ui-small);
  }
}

/* On the style page the component sits framed at the width the hero column
 * gives it, so the demo previews what the home page ships. */
.md-typeset .proof-demo {
  max-width: calc(var(--measure) * 0.8);
  margin: var(--space-8) 0;
}

/* ── components ─────────────────────────────────────────────────────────────
 *
 * Four components carry most of the site: a project card, a publication entry,
 * a timeline entry, and a talk entry (#19).  They are defined once here so
 * that M4 and M5 assemble pages from decided parts rather than hand-styling
 * each occurrence, which is what makes a site read as designed rather than
 * assembled.  docs/design/style.md renders a live example of every one,
 * including the degraded cases, so a component that breaks breaks visibly on
 * one page.
 *
 * Authoring form is Markdown plus `attr_list` and `md_in_html`, never a raw
 * HTML block: a wrapper `<div class="…" markdown>` around ordinary Markdown,
 * and `{.class}` where a specific line needs naming.  What that means in
 * practice, per component, is on the style page.
 *
 * Two conventions used throughout, stated once:
 *
 * 1.  The type tokens are `em`-relative to the typeset base, so they compose:
 *     an entry set at --type-small (0.8125em) whose title is --type-h3
 *     (1.1875em) puts the title at 0.965em -- just under body size -- against
 *     14px metadata.  That compounding is deliberate and is the only way to
 *     get a size hierarchy inside a component without inventing a token.
 *
 * 2.  No component wraps itself in a link.  A card whose whole surface is
 *     clickable has to nest the title and action links inside that link, which
 *     is invalid, unreachable by keyboard in the way it looks, and destroys
 *     text selection.  Cards are <div>s; the title and the action links are
 *     ordinary sibling links, and each gets the global :focus-visible ring.
 *     Nothing here sets `overflow: hidden`, so that ring is never clipped.
 */

/* ── project card ─────────────────────────────────────────────────────────
 *
 *   <div class="project-grid" markdown>
 *   <div class="project-card" markdown>
 *   **[title](url)**
 *
 *   One-line summary.
 *
 *   `Agda`{.tag} `Nix`{.tag}
 *
 *   [Source](url) · [Docs](url)
 *   {.project-links}
 *   </div>
 *   </div>
 *
 * Every part after the title is optional.  The title is the first paragraph,
 * so it needs no class of its own; the links row needs one because it is
 * pinned to the bottom of the card, which is what keeps a row of cards of
 * different lengths aligned along their actions.
 */

.md-typeset .project-grid {
  display: grid;
  /* Half a text column is the point at which a card stops being a card and
     starts being a paragraph with a border, so that is the track minimum.
     `min(100%, …)` keeps a single card from overflowing a narrow viewport. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--measure) / 2)), 1fr));
  gap: var(--space-4);
  margin: var(--space-8) 0;
}

.md-typeset .project-card {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  background-color: var(--c-bg-raised);
  border: var(--border-width) solid var(--c-line);
  border-radius: var(--radius-md);
  padding: var(--space-4);
  font-size: var(--type-small);
  color: var(--c-fg-muted);
  transition: border-color 120ms;
}

/* Hover and keyboard focus are deliberately different colours: hover is a
   passing pointer, focus is where the keyboard actually is. */
.md-typeset .project-card:hover {
  border-color: var(--c-line-strong);
}

.md-typeset .project-card:focus-within {
  border-color: var(--c-accent);
}

/* The flex `gap` owns the vertical rhythm inside a card; Material's paragraph
   margins would otherwise be added to it. */
.md-typeset .project-card > * {
  margin: 0;
}

.md-typeset .project-card > p:first-child {
  font-family: var(--font-display), var(--font-body), sans-serif;
  font-size: var(--type-h3);
  font-weight: var(--display-weight);
  letter-spacing: var(--tracking-display);
  line-height: var(--leading-heading);
  color: var(--c-fg);
}

/* The title is authored as `**…**`, so the paragraph's weight is not the one
   that lands: `.md-typeset strong` sets its own, and it wins over inheritance.
   Under Constellation both tokens are 600 and nothing shows; under Meridian
   --display-weight is 500, and the card title would be the one component title
   that ignored it.  Measured, not assumed: forcing --display-weight to 500
   moves the publication title, the talk title and every heading to 500 and
   leaves this one at 600 without this rule. */
.md-typeset .project-card > p:first-child > strong {
  font-weight: var(--display-weight);
}

/* A title that links somewhere reads as a link; one that does not is still a
   title.  The bottom rule under body links is noise at this size. */
.md-typeset .project-card > p:first-child a {
  border-bottom: none;
}

.md-typeset .project-links {
  /* Pushed to the bottom, so cards in a row line their actions up. */
  margin-top: auto;
  padding-top: var(--space-2);
}

/* ── tag ──────────────────────────────────────────────────────────────────
 *
 *   `Agda`{.tag}   `invited`{.tag}   `dormant`{.tag}
 *
 * A code span, because `attr_list` attaches to elements Markdown produces and
 * a bare `[text]{.tag}` is not one of them -- it renders literally.  `em` and
 * `strong` were the alternatives and both already carry meaning here (venue,
 * emphasised author), so the neutral one is the right host.  It is restyled
 * out of the code face: these are labels, not code.
 */

.md-typeset .tag {
  display: inline-block;
  /* An inline-block is a block container, so it inherits the hanging indent
     the publication and talk entries set and applies it to its own first
     line -- which slides the label out of its own border box.  Caught by
     looking at a rendered talk entry with a tag on it, not by reading this. */
  text-indent: 0;
  font-family: var(--font-body), sans-serif;
  font-size: var(--type-small);
  font-weight: 400;
  line-height: var(--leading-heading);
  color: var(--c-fg-muted);
  background-color: transparent;
  border: var(--border-width) solid var(--c-line-strong);
  border-radius: var(--radius-sm);
  padding: 0 var(--space-2);
  white-space: nowrap;
}

/* ── publication entry, and talk entry ────────────────────────────────────
 *
 *   <div class="publications" markdown>
 *   - **Title**
 *     Authors, with **William DeMeo** emphasised.  *Venue*, **volume**:pages, year.
 *     [DOI](url) · [arXiv:id](url) · [PDF](url)
 *   </div>
 *
 * (each entry line ends with two spaces, so the lines stay one paragraph)
 *
 * A talk entry is the same grammar with a different second line -- venue,
 * location, year -- and the slides link on the title rather than in a row of
 * their own.  They share these rules rather than being copies of each other,
 * because they are the same shape: a title, a line of attribution, and an
 * optional line of links.  Where they differ, the rules are separated below.
 *
 * The publication list is written by `gen_publications.py` from
 * `bibliography.json` (M5-1, #29): the generator emits exactly this Markdown
 * and knows nothing about these classes, so the page wraps its output and adds
 * nothing.  Every optional part is optional in the generator too -- an entry
 * with no DOI, no arXiv identifier and no venue simply has fewer lines.
 */

.md-typeset .publications ul,
.md-typeset .talks ul {
  list-style: none;
  margin-left: 0;
  padding-left: 0;
  max-width: var(--measure);
}

.md-typeset .publications li,
.md-typeset .talks li {
  margin: 0 0 var(--space-6);
  /* A hanging indent, the way a reference list has always been set.
     `text-indent` applies to the first line of the block only, and it is
     inherited, so it reaches the <p> that a loose list wraps each item in --
     which is why this works whether or not the source has blank lines between
     entries.  The lines after each <br> keep the padding. */
  padding-left: var(--space-6);
  text-indent: calc(-1 * var(--space-6));
  font-size: var(--type-small);
  color: var(--c-fg-muted);
}

.md-typeset .publications li:last-child,
.md-typeset .talks li:last-child {
  margin-bottom: 0;
}

/* Material gives a list item inside a loose list its own paragraph margins. */
.md-typeset .publications li > p,
.md-typeset .talks li > p {
  margin: 0;
}

/* The emphasised author -- and the volume number, which is marked up the same
   way -- steps forward out of the muted metadata. */
.md-typeset .publications li strong,
.md-typeset .talks li strong {
  color: var(--c-fg);
}

.md-typeset .publications li > strong:first-child,
.md-typeset .publications li > p:first-child > strong:first-child,
.md-typeset .talks li > strong:first-child,
.md-typeset .talks li > p:first-child > strong:first-child {
  font-family: var(--font-display), var(--font-body), sans-serif;
  font-size: var(--type-h3);
  font-weight: var(--display-weight);
  letter-spacing: var(--tracking-display);
}

/* A linked title is the artifact itself -- the slides, for a talk -- so it
   carries the accent.  A talk with no slides is a title in plain text, and
   that is the whole of the degradation. */
.md-typeset .publications li > strong:first-child a,
.md-typeset .publications li > p:first-child > strong:first-child a,
.md-typeset .talks li > strong:first-child a,
.md-typeset .talks li > p:first-child > strong:first-child a {
  border-bottom: none;
}

/* ── timeline entry ───────────────────────────────────────────────────────
 *
 *   <div class="timeline" markdown>
 *   2023–
 *   :   **Formal Verification Engineer**, Formal Methods — [IO](url)
 *       What the position actually was.
 *   </div>
 *
 * A definition list, because that is what a timeline is: a date, and what
 * happened then.  `def_list` is already enabled, the source reads as the thing
 * it describes, and the CV's education and grants sections are written this
 * way already -- wrapping them is the whole migration.
 *
 * An open-ended range is `2023–` with nothing after the dash, which needs no
 * support here beyond not assuming two dates.
 */

.md-typeset .timeline dl {
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: var(--space-6);
  /* No row gap: the rule down the left of the second column is continuous
     only if the rows touch, so the spacing between entries is padding on the
     rows themselves. */
  row-gap: 0;
  max-width: var(--measure-heading);
  margin: var(--space-8) 0;
}

/* No bottom padding here, deliberately.  A grid row is as tall as its tallest
   item, so padding on the date would set the row height whenever it exceeded
   the entry's -- which is exactly the last row, where the entry's padding is
   removed.  The stretched entry's rule would then hang below its own last line
   by the height of that padding.  The spacing between rows is the entry's job
   alone; the date is one line and never the taller of the two. */
.md-typeset .timeline dt {
  grid-column: 1;
  margin: 0;
  /* Flush with the margin, not ragged against the entry: an open range such
     as `2023–` otherwise floats right of the closed ones above and below it
     (William, on review of #42).  The PDF's date gutter is set the same way. */
  text-align: left;
  font-size: var(--type-small);
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  color: var(--c-fg-muted);
}

.md-typeset .timeline dd {
  position: relative;
  grid-column: 2;
  margin: 0;
  padding-left: var(--space-4);
  padding-bottom: var(--space-6);
  border-left: var(--border-width) solid var(--c-line);
  font-size: var(--type-small);
  color: var(--c-fg-muted);
}

.md-typeset .timeline dd:last-of-type {
  padding-bottom: 0;
}

/* The node on the line.  Centred on the rule, and set against the first line
   of the entry rather than its middle, so entries of different heights line
   their dots up with their dates. */
.md-typeset .timeline dd::before {
  content: "";
  position: absolute;
  top: var(--space-2);
  left: calc(-0.5 * (var(--space-1) + var(--border-width)));
  width: var(--space-1);
  height: var(--space-1);
  border-radius: 50%;
  background-color: var(--c-accent);
}

.md-typeset .timeline dd strong {
  color: var(--c-fg);
}

/* Material's mobile breakpoint.  Below it the date gutter costs more width
   than it earns, so the entry stacks under its date and keeps the rule. */
@media screen and (max-width: 44.9375em) {
  .md-typeset .timeline dl {
    display: block;
  }

  .md-typeset .timeline dt {
    text-align: left;
    padding-bottom: var(--space-1);
  }
}

@media print {
  .md-typeset > p,
  .md-typeset > ul,
  .md-typeset > ol {
    max-width: none;
  }

  /* A card grid prints as a list; two narrow columns on paper are worse than
     one full-width one. */
  .md-typeset .project-grid {
    display: block;
  }

  .md-typeset .project-card {
    margin-bottom: var(--space-4);
  }
}
