/* --- 00. Display face ---------------------------------------------------- */

/* Space Grotesk, one weight, latin subset, self-hosted and stamped with its own
   hash. 12.56 KB gzip.

   Why this one and not the other three on the table, all measured the same way:

     Space Grotesk   12.56 KB      Instrument Sans  16.64 KB
     Archivo         14.19 KB      Sora             14.79 KB

   It is the cheapest, and it is the only one that descends from a monospace
   (Space Mono). The page's technical type is already mono, so the display and
   the code share a skeleton instead of arguing about one. It is used for the
   wordmark, the section titles and the figures, and nowhere else: body copy
   stays in the system stack, which is metrically stable and costs nothing.

   `font-display: swap` means the first paint uses the fallback and never blocks.
   The fallback is the same geometric sans the rest of the page already uses, so
   the swap is a weight change and not a reflow of the layout. */
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("../fonts/space-grotesk-700.35f8aec56cfd.woff2") format("woff2");
  unicode-range:
    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
    U+2215, U+FEFF, U+FFFD;
}

/* ==========================================================================
   HECATE · visual system
   --------------------------------------------------------------------------
   Direction: signal room. Three accents with a fixed meaning (lime = action,
   cyan = data, violet = adapter, amber = fault). Deep ink ground, three
   elevation levels, editorial rhythm. No webfonts, no bitmaps, no JS needed
   to read the page.

   The contract for this file is site/DESIGN.md.
   ========================================================================== */

/* --- 1. Tokens ----------------------------------------------------------- */

/* One palette, two themes. light-dark() resolves against the used colour
   scheme, so an explicit choice in the theme switcher only has to declare
   the scheme and never duplicates a single token. */
:root {
  color-scheme: dark light;

  --font-sans:
    system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono:
    ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
    monospace;
  /* The display face, with the neutral sans as a metric-compatible fallback:
     a failed font request degrades to the page's own voice, not to Times. */
  --font-display:
    "Space Grotesk", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

  --ink: light-dark(#f7f8f5, #05070a);
  --ink-2: light-dark(#ffffff, #0a0e13);
  --surface: light-dark(#ffffff, #0f151c);
  --surface-2: light-dark(#f1f4f0, #141c25);
  --surface-3: light-dark(#e7ece6, #1b2530);
  --line: light-dark(rgba(10, 18, 22, 0.12), rgba(255, 255, 255, 0.09));
  --line-hi: light-dark(rgba(10, 18, 22, 0.26), rgba(255, 255, 255, 0.18));
  --text: light-dark(#0a1216, #f2f6f8);
  --text-dim: light-dark(#4a5a64, #9fb0bc);

  --lime: light-dark(#47660a, #c6f24e);
  --cyan: light-dark(#0f766e, #4fd1c5);
  --violet: light-dark(#5b45d6, #8b7bff);
  --amber: light-dark(#8a5a00, #e0a33e);

  --field-lime: light-dark(rgba(71, 102, 10, 0.05), rgba(198, 242, 78, 0.055));
  --field-cyan: light-dark(rgba(15, 118, 110, 0.05), rgba(79, 209, 197, 0.05));
  --field-violet: light-dark(
    rgba(91, 69, 214, 0.05),
    rgba(139, 123, 255, 0.05)
  );
  --grain-opacity: 0.1;

  --shadow-color: light-dark(rgba(10, 18, 22, 0.09), rgba(0, 0, 0, 0.28));
  --shadow-1: 0 1px 0 var(--line-hi) inset, 0 1px 2px var(--shadow-color);
  --shadow-2: 0 0 0 1px var(--line), 0 18px 40px -24px var(--shadow-color);
  --shadow-3: 0 0 0 1px var(--line-hi), 0 40px 90px -40px var(--shadow-color);

  --ring-lime: light-dark(rgba(71, 102, 10, 0.45), rgba(198, 242, 78, 0.4));
  --blur-lime: light-dark(rgba(71, 102, 10, 0.3), rgba(198, 242, 78, 0.3));
  --ring-cyan: light-dark(rgba(15, 118, 110, 0.4), rgba(79, 209, 197, 0.34));
  --blur-cyan: light-dark(rgba(15, 118, 110, 0.26), rgba(79, 209, 197, 0.26));
  --ring-violet: light-dark(rgba(91, 69, 214, 0.4), rgba(139, 123, 255, 0.34));
  --blur-violet: light-dark(rgba(91, 69, 214, 0.26), rgba(139, 123, 255, 0.26));
  --glow-lime: 0 0 0 1px var(--ring-lime), 0 0 28px -6px var(--blur-lime);
  --glow-cyan: 0 0 0 1px var(--ring-cyan), 0 0 24px -8px var(--blur-cyan);
  --glow-violet: 0 0 0 1px var(--ring-violet), 0 0 24px -8px var(--blur-violet);

  --s-1: 0.25rem;
  --s-2: 0.5rem;
  --s-3: 0.75rem;
  --s-4: 1rem;
  --s-5: 1.5rem;
  --s-6: 2rem;
  --s-7: 3rem;
  --s-8: 4.5rem;
  --s-9: 7rem;

  --r-1: 4px;
  --r-2: 12px;
  --r-3: 24px;
  --r-full: 999px;

  --e-out: cubic-bezier(0.22, 1, 0.36, 1);
  --e-io: cubic-bezier(0.76, 0, 0.24, 1);
  --e-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --d-1: 120ms;
  --d-2: 240ms;
  --d-3: 480ms;
  --d-4: 900ms;

  --z-canvas: 0;
  --z-content: 10;
  --z-header: 20;
  --z-overlay: 30;

  --display: clamp(3.2rem, 1.4rem + 7vw, 8.5rem);
  --step-5: clamp(2rem, 1.2rem + 2.6vw, 3.4rem);
  --step-4: clamp(1.5rem, 1.1rem + 1.3vw, 2.1rem);
  --step-3: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem);
  --step-2: clamp(1.1rem, 1rem + 0.5vw, 1.35rem);
  --step-0: 1rem;
  --step--1: 0.875rem;
  --step--2: 0.78rem;

  /* Data voice: a figure is evidence, not decoration. */
  --figure-hero: clamp(3rem, 1.6rem + 5.2vw, 5.5rem);
  --figure-lg: clamp(2rem, 1.4rem + 2.2vw, 3.25rem);
  --figure-md: clamp(1.5rem, 1.25rem + 1vw, 2rem);
  --figure-label: clamp(0.72rem, 0.68rem + 0.2vw, 0.8rem);
  /* Tracking for display numerals: large figures need negative tracking to
     avoid looking loose next to the mono label underneath. */
  --figure-tracking: -0.02em;
  /* Ink of a figure. A variable and not a literal so the dark/light switch and
     any future accent change move the figures with the rest of the palette. */
  --figure-color: var(--cyan);

  --measure: 64ch;
  --measure-narrow: 46ch;
  --wrap: 78rem;
}

/* The theme switcher only declares the scheme. */
:root[data-theme="dark"] {
  color-scheme: dark;
}

:root[data-theme="light"] {
  color-scheme: light;
}

@media (prefers-contrast: more) {
  :root {
    --line: light-dark(rgba(10, 18, 22, 0.5), rgba(255, 255, 255, 0.45));
    --line-hi: light-dark(rgba(10, 18, 22, 0.75), rgba(255, 255, 255, 0.7));
    --text-dim: light-dark(#23333c, #d6e2e8);
  }
}

/* --- 2. Base ------------------------------------------------------------- */

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

html {
  background: var(--ink);
  -webkit-text-size-adjust: 100%;
  scroll-padding-top: 6rem;
}

body {
  margin: 0;
  background: transparent;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--step-0);
  line-height: 1.62;
  overflow-x: hidden;
  overflow-wrap: break-word;
}

/* Layered ground: three radial fields with different falloff. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  background:
    radial-gradient(
      1100px 680px at 14% -8%,
      var(--field-lime),
      transparent 62%
    ),
    radial-gradient(900px 620px at 86% 2%, var(--field-cyan), transparent 60%),
    radial-gradient(
      760px 900px at 52% 118%,
      var(--field-violet),
      transparent 58%
    );
}

/* Grain and vignette. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: var(--grain-opacity);
  /* No mix-blend-mode here: a blended fixed layer has to be recomposed with
     the page on every scroll, and that is what costs frames while reading. */
  background-image:
    radial-gradient(
      120% 100% at 50% 50%,
      transparent 42%,
      rgba(0, 0, 0, 0.55) 100%
    ),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)'/%3E%3C/svg%3E");
}

h1,
h2,
h3 {
  margin: 0;
  line-height: 1.16;
  font-weight: 600;
  letter-spacing: -0.015em;
}

p {
  margin: 0;
}

ul,
ol {
  margin: 0;
  padding: 0;
}

a {
  color: var(--lime);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.2em;
}

a:hover {
  color: var(--text);
}

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

code,
pre {
  font-family: var(--font-mono);
}

code {
  font-size: 0.9em;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-1);
  padding: 0.05em 0.32em;
}

pre {
  margin: 0;
  font-size: var(--step--1);
  line-height: 1.7;
  tab-size: 2;
}

pre code {
  background: none;
  border: 0;
  padding: 0;
  font-size: 1em;
}

.container {
  width: min(100% - 2.5rem, var(--wrap));
  margin-inline: auto;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 0.6rem 0.9rem;
  background: var(--ink-2);
  color: var(--text);
  border: 1px solid var(--lime);
  border-radius: var(--r-1);
  font-size: var(--step--1);
  text-decoration: none;
}

.skip-link:focus {
  left: 0.75rem;
  top: 0.75rem;
}

/* --- 3. Header ----------------------------------------------------------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: var(--z-header);
  /* Opaque on purpose. A backdrop-filter over the live hero canvas forces the
     browser to re-rasterise the blur on every frame, and it was the most
     expensive layer on the page. An opaque ground reads the same and is free. */
  background: var(--ink-2);
  border-bottom: 1px solid var(--line);
}

.site-header--plain {
  position: static;
}

.reading-bar {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 2px;
  background: var(--line);
}

.reading-bar__fill {
  display: block;
  height: 100%;
  width: 100%;
  transform: scaleX(0);
  transform-origin: 0 50%;
  background: linear-gradient(90deg, var(--cyan), var(--lime));
}

.site-header__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem 1.5rem;
  padding-block: 0.85rem;
}

.brand {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
  font-family: var(--font-mono);
  font-size: var(--step-2);
  font-weight: 700;
  letter-spacing: 0.1em;
  color: var(--text);
  text-decoration: none;
}

.brand__dot {
  width: 6px;
  height: 6px;
  border-radius: var(--r-full);
  background: var(--lime);
  box-shadow: 0 0 12px -2px var(--lime);
}

/* The section menu.
   ------------------------------------------------------------------------
   Above 72rem the eight links are a row inside the header. Below it they were
   wrapped into two rows, which made a sticky header 196px tall on a 390px
   phone: 23% of the screen, permanently, on every scroll position. Now the
   same links live in a panel behind a toggle, so the header keeps one row at
   every width.

   The panel is not a <details> on purpose. A closed <details> hides its
   children with a UA rule that cannot be overridden, and the desktop layout
   needs those children to be visible without any open state. So the collapsed
   layout is enabled by the module, which adds `nav-ready` to <html>: with
   JavaScript off nothing is hidden and nothing is unreachable, the links just
   wrap as they did before. */
.site-nav {
  display: contents;
}

.site-nav__toggle {
  display: none;
  align-items: center;
  gap: 0.45rem;
  min-height: 44px;
  padding: 0 0.7rem;
  border: 1px solid var(--line-hi);
  border-radius: var(--r-1);
  background: var(--surface);
  color: var(--text);
  font: 500 var(--step--1) / 1 var(--font-sans);
  cursor: pointer;
  transition:
    border-color var(--d-1) var(--e-out),
    color var(--d-1) var(--e-out);
}

.site-nav__toggle:hover {
  border-color: var(--lime);
  color: var(--lime);
}

/* Three bars from one element and two pseudo elements: no icon font, no SVG
   request, and the open state rotates them into a cross. */
.site-nav__bars,
.site-nav__bars::before,
.site-nav__bars::after {
  display: block;
  width: 16px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition:
    transform var(--d-2) var(--e-out),
    background-color var(--d-1) var(--e-out);
}

.site-nav__bars {
  position: relative;
}

.site-nav__bars::before,
.site-nav__bars::after {
  content: "";
  position: absolute;
  left: 0;
}

.site-nav__bars::before {
  top: -5px;
}

.site-nav__bars::after {
  top: 5px;
}

.site-nav__toggle[aria-expanded="true"] .site-nav__bars {
  background: transparent;
}

.site-nav__toggle[aria-expanded="true"] .site-nav__bars::before {
  transform: translateY(5px) rotate(45deg);
}

.site-nav__toggle[aria-expanded="true"] .site-nav__bars::after {
  transform: translateY(-5px) rotate(-45deg);
}

/* Only once the module is running does the compact header exist. */
.nav-ready .site-nav {
  margin-inline-start: auto;
}

.nav-ready .site-nav__toggle {
  display: inline-flex;
}

/* 72rem and not 68: with the eight labels, the status pill and the theme
   switch the desktop row needs about 1070px of inner width. At 68rem the
   container is 1048px, so the row wrapped and the header grew to two lines. */
@media (max-width: 71.999rem) {
  /* Before nav-ready, keep the fallback links visible in an open overlay and
     the header static. The overlay can scroll away without JavaScript, while
     activating the compact menu never changes the document flow. */
  .site-header:not(.site-header--plain) {
    position: static;
  }

  .site-header:not(.site-header--plain) .site-header__inner {
    flex-wrap: nowrap;
    padding-block: 0.5rem;
  }

  .site-header:not(.site-header--plain) .site-nav {
    display: block;
    position: static;
    margin-inline-start: auto;
  }

  .site-header:not(.site-header--plain) .site-nav__panel {
    position: absolute;
    left: 0;
    right: 0;
    top: 100%;
    padding: 0.35rem 1.25rem 1.1rem;
    border-bottom: 1px solid var(--line-hi);
    background: var(--ink-2);
    box-shadow: var(--shadow-3);
  }

  .nav-ready .site-header:not(.site-header--plain) {
    position: sticky;
  }

  .nav-ready .site-header__inner {
    flex-wrap: nowrap;
    padding-block: 0.5rem;
  }

  .nav-ready .site-nav {
    display: block;
    position: static;
    margin-inline-start: auto;
  }

  .nav-ready .site-nav__panel {
    position: absolute;
    left: 0;
    right: 0;
    top: 100%;
    display: none;
    padding: 0.35rem 1.25rem 1.1rem;
    border-bottom: 1px solid var(--line-hi);
    background: var(--ink-2);
    box-shadow: var(--shadow-3);
    max-height: calc(100dvh - 5rem);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .nav-ready .site-nav[data-open="true"] .site-nav__panel {
    display: block;
  }

  /* One row per section, 48px tall: the whole point of the panel is that a
     thumb can hit a link without aiming. */
  .nav-ready .primary-nav {
    margin: 0;
  }

  .nav-ready .primary-nav ul {
    flex-direction: column;
    gap: 0;
    font-size: var(--step-0);
  }

  .nav-ready .primary-nav li + li {
    border-top: 1px solid var(--line);
  }

  .nav-ready .primary-nav a {
    position: relative;
    display: flex;
    align-items: center;
    min-height: 48px;
    padding-inline-start: 0.75rem;
    color: var(--text);
  }

  .nav-ready .primary-nav a::before {
    content: "";
    position: absolute;
    left: 0;
    top: 50%;
    width: 3px;
    height: 24px;
    background: var(--lime);
    transform: translateY(-50%) scaleY(0);
    transition: transform var(--d-1) var(--e-out);
  }

  .nav-ready .primary-nav a[aria-current="true"] {
    font-weight: 700;
  }

  .nav-ready .primary-nav a[aria-current="true"]::before {
    transform: translateY(-50%) scaleY(1);
  }

  /* The underline grows out of the left edge, which reads as a broken rule in
     a stacked list. The full-width row is the target instead. */
  .nav-ready .primary-nav a::after {
    display: none;
  }

  .nav-ready .site-nav__panel .status-pill {
    margin: 0.9rem 0 0;
    padding: 0.3rem 0.7rem;
    font-size: var(--step--1);
  }

  .nav-ready .theme-switch button {
    padding: 0.35rem 0.5rem;
  }
}

/* Headers that print their navigation inline and carry no compact menu.
 *
 * Below 72rem the header is pinned to one row, which is right on the home page:
 * the menu collapses into a toggle and has to stay beside the wordmark. The
 * seven project pages and the documentation have nothing to collapse, so a
 * single row simply pushed the theme switch past the right edge — 49px at 320px,
 * and the whole page scrolled sideways because of it. Where there is nothing to
 * collapse, a second row is the honest answer. */
@media (max-width: 71.999rem) {
  .site-header:not(.site-header--plain):not(:has(.site-nav)) .site-header__inner {
    flex-wrap: wrap;
  }
}

@media (min-width: 72rem) {
  .site-nav__panel {
    display: contents;
  }

  .nav-ready .site-nav {
    margin-inline-start: 0;
  }

  .nav-ready .site-nav__toggle {
    display: none;
  }
}

/* A phone has no room for the word next to the bars, and at 320px there is no
   room for the full width of the theme switch either: the three groups have to
   shrink or the header overflows sideways. */
@media (max-width: 25.999rem) {
  .nav-ready .site-header__inner {
    gap: 0.4rem;
  }

  .nav-ready .site-nav__toggle {
    padding-inline: 0.55rem;
  }

  .nav-ready .site-nav__toggle-text {
    display: none;
  }

  .nav-ready .theme-switch {
    padding: 1px;
    gap: 1px;
  }

  .nav-ready .theme-switch button {
    padding-inline: 0.35rem;
  }

  .nav-ready .brand {
    font-size: 1.05rem;
    letter-spacing: 0.06em;
  }
}

/* Once the page moves, the header separates itself from the content it is
   covering. Without the cue a sticky translucent bar reads as part of the
   page and the blur looks like a rendering artefact. */
.site-header.is-scrolled {
  border-bottom-color: var(--line-hi);
  box-shadow: 0 12px 32px -28px var(--shadow-color);
}

.primary-nav {
  margin-inline-start: auto;
}

.primary-nav ul {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1.15rem;
  list-style: none;
  font-size: var(--step--1);
}

.primary-nav a {
  position: relative;
  color: var(--text-dim);
  text-decoration: none;
}

.primary-nav a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -3px;
  height: 1px;
  background: var(--lime);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform var(--d-1) var(--e-out);
}

.primary-nav a:hover,
.primary-nav a[aria-current="true"] {
  color: var(--text);
}

.primary-nav a:hover::after,
.primary-nav a[aria-current="true"]::after {
  transform: scaleX(1);
}

.site-header__tools {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 0.75rem;
}

.status-pill {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  margin: 0;
  padding: 0.2rem 0.6rem;
  border: 1px solid var(--line-hi);
  border-radius: var(--r-full);
  background: var(--surface);
  font-family: var(--font-mono);
  font-size: var(--step--2);
  color: var(--text-dim);
  white-space: nowrap;
}

.status-pill__dot {
  width: 6px;
  height: 6px;
  border-radius: var(--r-full);
  background: var(--lime);
  box-shadow: 0 0 10px -1px var(--lime);
}

.theme-switch {
  display: inline-flex;
  padding: 2px;
  gap: 2px;
  border: 1px solid var(--line);
  border-radius: var(--r-full);
  background: var(--surface);
}

.theme-switch button {
  appearance: none;
  border: 0;
  border-radius: var(--r-full);
  /* 24px is the WCAG 2.5.8 floor for a mouse target; a finger gets 44px from
     the coarse-pointer block. Without the floor the segment measures 23.8px. */
  min-height: 24px;
  padding: 0.2rem 0.6rem;
  background: transparent;
  color: var(--text-dim);
  font: 500 var(--step--2) / 1.4 var(--font-mono);
  cursor: pointer;
  transition:
    color var(--d-1) var(--e-out),
    background-color var(--d-1) var(--e-out);
}

.theme-switch button:hover {
  color: var(--text);
}

.theme-switch button[aria-pressed="true"] {
  background: var(--surface-3);
  color: var(--text);
  box-shadow: var(--shadow-1);
}

/* --- 4. Buttons ---------------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.95rem;
  border: 1px solid var(--line-hi);
  border-radius: var(--r-1);
  background: var(--surface);
  color: var(--text);
  font: 500 var(--step--1) / 1.2 var(--font-sans);
  text-decoration: none;
  cursor: pointer;
  transition:
    transform var(--d-1) var(--e-out),
    border-color var(--d-1) var(--e-out),
    background-color var(--d-1) var(--e-out),
    color var(--d-1) var(--e-out);
}

.btn:hover {
  border-color: var(--lime);
  color: var(--lime);
  transform: translateY(-2px);
}

/* Three states on one control, three different answers: hover lifts 2px, press
   drops 1px below rest and loses the elevation, focus keeps the outline. The press
   used to return to 0, which reads as "undo the hover" rather than "you hit it". */
.btn:active {
  transform: translateY(1px);
  box-shadow: none;
}

.btn[disabled] {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

.btn--primary {
  background: var(--lime);
  border-color: var(--lime);
  color: var(--ink);
  font-weight: 600;
}

.btn--primary:hover {
  color: var(--ink);
  box-shadow: var(--glow-lime);
}

.btn--secondary {
  background: transparent;
}

/* A control that cannot act any more keeps its focus: `aria-disabled` leaves
   it focusable, and the browser would drop the focus if it were `disabled`. */
.btn[aria-disabled="true"],
.btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

/* --- 5. Hero ------------------------------------------------------------- */

.hero {
  position: relative;
  isolation: isolate;
  /* Short enough that the metrics band is visible in the first screen: the
     band is the second thing the eye lands on, after the wordmark. */
  min-height: min(72vh, 46rem);
  display: flex;
  align-items: center;
  overflow: hidden;
  border-bottom: 1px solid var(--line);
}

.hero__canvas {
  position: absolute;
  inset: 0;
  z-index: var(--z-canvas);
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.hero__glow {
  position: absolute;
  inset: -20% -10%;
  z-index: 1;
  pointer-events: none;
  /* No mix-blend-mode: screen. The two blobs below are animated on endless loops
  of 20 and 34 seconds, so a blend mode declared here is a pass the compositor
  can never cache: every frame re-blends two 736px squares against the hero,
  forever, not only while scrolling. The blobs already lighten the near black
  base through their own alpha, which is the same effect without the pass. */
}

.hero__glow-a,
.hero__glow-b {
  position: absolute;
  display: block;
  width: 46rem;
  height: 46rem;
  border-radius: 50%;
  /* No filter: blur(90px). Each blob is a radial-gradient that is already fully
  transparent at its edge, so the blur spent 90px of GPU work per frame softening
  a shape that was soft already. Removing it leaves the painted area and the peak
  alpha in the same range, as the lattice audit reports. */
}

.hero__glow-a {
  top: -14rem;
  left: -8rem;
  background: radial-gradient(
    circle,
    rgba(198, 242, 78, 0.16),
    transparent 66%
  );
}

.hero__glow-b {
  right: -10rem;
  bottom: -18rem;
  background: radial-gradient(
    circle,
    rgba(79, 209, 197, 0.14),
    transparent 68%
  );
}

.hero__inner {
  position: relative;
  z-index: var(--z-content);
  padding-block: var(--s-7);
  display: grid;
  gap: var(--s-4);
  justify-items: start;
}

/* Measured at 1440px before this rule: every element in the hero started at
   x=88 and the widest one ended at x=994, so 342px of a 1248px container held
   nothing at all, from the wordmark to the buttons. Six elements stacked on one
   left edge is the shape of a document, not of a page that was composed. The
   version and licence rail is metadata, which is the one thing that belongs in
   a corner: it moves to the right of the action row, so the eye travels
   title -> claim -> buttons and finds a small anchor at the far edge. It stays
   a grid area rather than absolute positioning, so nothing can overlap the
   claim at any width, and below 62rem the hero goes back to one column. */
@media (min-width: 62rem) {
  .hero__inner {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas:
      "title  title"
      "byline byline"
      "claim  claim"
      "sub    sub"
      "aud    aud"
      "acts   meta";
  }

  .hero__inner > .hero__title {
    grid-area: title;
  }

  .hero__inner > .hero__byline {
    grid-area: byline;
  }

  .hero__inner > .hero__claim {
    grid-area: claim;
  }

  .hero__inner > .hero__sub {
    grid-area: sub;
  }

  .hero__inner > .hero__audience {
    grid-area: aud;
  }

  .hero__inner > .hero__actions {
    grid-area: acts;
    align-self: center;
  }

  .hero__inner > .hero__meta {
    grid-area: meta;
    align-self: center;
    justify-self: end;
  }
}

.hero__title {
  font-family: var(--font-display);
  font-size: var(--display);
  font-weight: 700;
  /* Measured at 1440px on the wordmark: -0.035em left 0.57em of air between the
     six letters, which at 123.2px is roughly a 70px gap distributed across the
     word. -0.052em closes it to 0.53em and the wordmark goes from 412px to 400px
     wide, a 2.9% tightening; at 360px it goes from 171px to 166px, the same
     2.9%. Line height, wrap, call-to-action position and hero height do not move,
     so the five-second test is undisturbed. Display type is set tighter than body
     copy on purpose, and this is the only display-sized line on the page. */
  letter-spacing: -0.052em;
  line-height: 0.88;
  /* Solid on purpose. The wordmark is the largest contentful paint, and a
     background-clip:text fill has to rasterise and clip before it paints. An
     early sample read LCP 1541ms with the gradient, but three runs without it
     read 1351, 1379 and 1380ms, so that sample was run-to-run noise and this
     is a conservative choice, not a measured win. The gradient lives on the
     section numerals instead, which are below the fold. */
  color: var(--text);
}

/* The hero used to be a person's name and is a wordmark again, so the name
   moved under it rather than being deleted: a portfolio whose first line is a
   project still has to say whose project it is, and the thing a reader does
   with that line is look it up. It sits between the wordmark and the claim, in
   mono and one step down, so the eye reads HECATE, then the author, then what
   HECATE is. The `--name` variant that set the title at 4.15rem was built for
   the four-word name and is gone with it; the base rule below is the display
   size the six-letter wordmark was designed at. */
.hero__byline {
  margin: calc(-1 * var(--s-2)) 0 0;
  font-family: var(--font-mono);
  font-size: var(--step--1);
  letter-spacing: 0.02em;
  color: var(--text-dim);
}

.hero__claim {
  /* Measured at 1440px: 22ch held the claim to 664px, which at 54.4px is 24
     characters a line and four lines — a column of words rather than a
     sentence. The references set their display sentence across the plane, not
     down it. 30ch opens the measure to about 880px and lands the same 16 words
     on three lines, which is the shape the eye reads as a statement. It stays
     a ch measure and not a px width, so a phone keeps a sane line. */
  max-width: 30ch;
  font-size: var(--step-5);
  font-weight: 600;
  line-height: 1.12;
  letter-spacing: -0.02em;
  color: var(--text);
  /* The claim is now two sentences instead of one, so the last line would
  otherwise carry a single orphan word. Balanced wrapping spreads the same
  text over the same number of lines, so no other measurement moves. */
  text-wrap: balance;
}

.hero__sub {
  max-width: 52ch;
  font-size: var(--step-2);
  color: var(--text-dim);
}

/* Who the page is for. A mono eyebrow, not a paragraph: it answers one
   question and gets out of the way. The lime dot is the same signal the
   status pill uses, so it reads as a label rather than as another sentence. */
.hero__audience {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  max-width: 52ch;
  margin-top: 0.85rem;
  font-family: var(--font-mono);
  font-size: var(--step--1);
  letter-spacing: 0.02em;
  color: var(--text);
}

.hero__audience::before {
  content: "";
  flex: none;
  width: 6px;
  height: 6px;
  border-radius: var(--r-full);
  background: var(--lime);
  transform: translateY(-1px);
}

.hero__meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1.25rem;
  list-style: none;
  font-family: var(--font-mono);
  font-size: var(--step--1);
  color: var(--text-dim);
  letter-spacing: 0.04em;
}

.hero__meta li {
  position: relative;
}

.hero__meta li + li::before {
  content: "";
  position: absolute;
  left: -0.7rem;
  top: 0.55em;
  width: 3px;
  height: 3px;
  border-radius: var(--r-full);
  background: var(--line-hi);
}

.hero__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: var(--s-2);
}

.hero__vignette {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background: radial-gradient(
    120% 90% at 50% 40%,
    transparent 40%,
    rgba(0, 0, 0, 0.55) 100%
  );
}

/* --- 6. Marquee ---------------------------------------------------------- */

.marquee {
  overflow: hidden;
  border-bottom: 1px solid var(--line);
  background: var(--ink-2);
  padding-block: 0.6rem;
  mask-image: linear-gradient(
    90deg,
    transparent,
    #000 8%,
    #000 92%,
    transparent
  );
}

.marquee__track {
  display: flex;
  gap: 2.5rem;
  width: max-content;
  will-change: transform;
}

.marquee__track span {
  font-family: var(--font-mono);
  font-size: var(--step--2);
  letter-spacing: 0.12em;
  color: var(--text-dim);
  white-space: nowrap;
}

/* --- 7. Sections --------------------------------------------------------- */

.section {
  display: grid;
  gap: var(--s-5);
  padding-block: var(--s-7);
  border-top: 1px solid var(--line);
  /* Sections used to run flush to the viewport edge below 62rem, because the two
  column grid only starts there: a phone had its body text starting at x=0 while
  the header started at 20. This is the container gutter, expressed the same way,
  so a section label lines up with the wordmark at every width: the same 1.25rem
  per side by default, and the same centring the container gets once the viewport
  is wider than the wrap. Padding does not shrink the border box, so the rule
  between sections still spans the full width. */
  padding-inline: max(1.25rem, (100% - var(--wrap)) / 2);
  /* The field is a background, not a pseudo-element: a background paints the
     same light and adds no box to the layout tree, which keeps the element
     count that ScrollTrigger has to measure down. */
  background-image: radial-gradient(
    760px 420px at 8% 0%,
    var(--field-cyan),
    transparent 68%
  );
  background-repeat: no-repeat;
}

.section__head {
  min-width: 0;
}

/* The section number is structure, not a caption: it is the first thing the
   eye lands on. It used to be 14px next to a 33.6px title, which read as dead
   weight. Now it is a display numeral, tabular so 01 and 07 occupy the same
   width, with a gradient that belongs to the palette and a hairline under it. */
.section__title {
  font-family: var(--font-display);
  /* Measured at 1440px against the three references: bamco.com sets its section
     headings at 54.5px and sulkinaskenazi.com at 82px, while this page set them
     at 33.6px — smaller than the 72px numeral above them, so the number read as
     the title and the title as a caption. 54.4px at the top of the clamp puts
     the heading level with bamco's and leaves the numeral in charge by 1.47x
     rather than 2.14x. The vw term is what keeps the pair apart on a phone: at
     360px the numeral is 36.2px and the title 28.1px, still a 1.29x ratio. */
  font-size: clamp(1.65rem, 1.15rem + 2.7vw, 3.4rem);
  letter-spacing: -0.02em;
  /* Stacked, the numeral and the title made a 123px block that occupied 240px
     of a 1248px row: in seven of the nine sections the other 1000px held
     nothing at all. Side by side they read as one banner, the block drops to
     63px, and the eye gets the number and the name in a single glance. It
     wraps rather than shrinking, so a long title on a phone still breaks
     cleanly under its numeral. */
  display: flex;
  flex-direction: row;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.2rem 0.9rem;
}

.section__number {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  /* Measured: 73px against a 54.4px title made the number the loudest thing in
     every section, nine times down the page. The numeral is the index, not the
     headline; at 44px it sits at 0.81x the title and leads with position
     instead of size. On a phone the two converge (28px against 28.1px), which
     is the one place where position alone still separates them. */
  font-size: clamp(1.75rem, 1.2rem + 2vw, 2.75rem);
  font-weight: 700;
  line-height: 0.82;
  letter-spacing: -0.05em;
  color: transparent;
  background-image: linear-gradient(140deg, var(--lime) 8%, var(--cyan) 92%);
  -webkit-background-clip: text;
  background-clip: text;
}

.section__number::after {
  content: "";
  display: block;
  width: 2.25rem;
  height: 2px;
  margin-top: 0.6rem;
  border-radius: 2px;
  background: linear-gradient(90deg, var(--lime), transparent);
  opacity: 0.6;
}

.section__body {
  min-width: 0;
}

/* Hanging figures. Measured across viewports: the section gutter is
   max(1.25rem, (100% - 1264px) / 2), so it is 20px from 992 to 1280, 40px at
   1344, 88px at 1440 and 168px at 1600. A 1.1rem hang needs a gutter of at
   least 44px to leave the numeral inside the viewport, which only holds from
   about 1352px; 85rem is the first round breakpoint above that. At 1440 it
   moves the numeral from x=88 to x=71 with no change to any heading height, no
   horizontal overflow, and no change to the document height, so the numeral
   sits in the margin the way a figure does in a book instead of being one more
   item in the flex row. Below 85rem the gutter is 20px and the hang would leave
   three, which is why this is behind a breakpoint and not a value. */
@media (min-width: 85rem) {
  .section__number {
    margin-inline-start: -1.1rem;
  }
}

@media (min-width: 62rem) {
  .section {
    grid-template-columns: 15rem minmax(0, 1fr);
    gap: var(--s-6) var(--s-6);
    /* Measured against the three reference sites at 1440px: this page ran a
       144px rhythm and 80% of its height was content, while sulkinaskenazi.com
       runs 192px (up to 270) at 61% and bamco.com 100px at 48%. Being the
       densest of the four is what makes a long page read as a document instead
       of a sequence of rooms. The clamp scales with the viewport instead of
       jumping at the breakpoint, and stops at 240px so a very wide screen does
       not turn every section into its own screenful. */
    padding-block: clamp(4.5rem, 3.6rem + 2.4vw, 7.5rem);
  }

  .section--wide > * {
    grid-column: 1 / -1;
  }

  .section--narrow {
    grid-template-columns: minmax(0, 1fr);
  }

  .section--narrow .section__body {
    max-width: var(--measure);
  }
}

/* The one section that turns around.

   Every section of this page runs the same way: a rail of structure on the
   left, the body on the right, five times. Measured at 1440, the heads of 01,
   02, 03 and 05 all start at x=88 and their titles at x=304. That is a
   composition; a composition repeated five times is a table.

   `#writing` is the middle of the document and its quietest section, so it is
   the one that turns: the head moves to the right edge and the numeral swaps
   to the outside of the title instead of the inside. The body keeps its left
   gutter, so the section reads as a diagonal and not as a mirror, and the
   sections around it go back to leading from the left. 04 is the only head on
   the page whose folio hangs in the right margin.

   Nothing here changes the reading order: the head is still first in the DOM
   and still carries the section's accessible name. */
@media (min-width: 62rem) {
  .section--flip > .section__head {
    justify-self: end;
  }

  .section--flip .section__title {
    flex-direction: row-reverse;
  }
}

@media (min-width: 85rem) {
  /* The hanging-figure rule above pulls `.section__number` 1.1rem into the
     left margin. On a flipped head that margin is on the other side of the
     viewport, so the hang moves with it rather than being dropped. */
  .section--flip .section__number {
    margin-inline-start: 0;
    margin-inline-end: -1.1rem;
  }
}

.prose {
  max-width: var(--measure);
  display: grid;
  gap: var(--s-4);
}

.prose p {
  color: var(--text-dim);
}

.prose .lead {
  color: var(--text);
  font-size: var(--step-2);
  line-height: 1.45;
  letter-spacing: -0.01em;
}

.lead {
  max-width: var(--measure-narrow);
}

/* The written-work list. Deliberately not a card grid: prose next to a list of
   documents is the one place on this page where the reading measure is the right
   width, because the items are sentences rather than tiles. The em dash is the
   separator so the item still reads as a line of prose when it wraps. */
.writing-list {
  display: grid;
  gap: 0.55rem;
  margin: 0;
  padding: 0;
  list-style: none;
  max-width: var(--measure);
  font-size: var(--step--1);
  color: var(--text-dim);
}

.writing-list code {
  font-size: 0.94em;
}

/* The About section is the one place where the reading measure cannot be the
   widest thing on the row, because the portrait has to sit beside the prose and
   not above it. Capping the body at 64ch caps the grid with it, which is how the
   block ended up 552px wide inside a 1248px container; the cap is lifted here
   for this body only, and the prose inside keeps its own measure. */
.section--narrow > .section__body:has(> .about) {
  max-width: none;
}

/* The About block. A portrait beside the prose, never inside the hero: a
   photograph in the hero turns the page into a CV, and this page is about what
   the person built.

   The portrait sits in the **right** column, mirroring `.proj-head` on the
   project pages, and for a measured reason: with the pair left-aligned inside a
   1248px container the block occupied 552px and left 696px of nothing beside it,
   so the section read as a column that had been pushed against the gutter while
   every other block on the page spans the full width. Keeping the prose on the
   container axis puts it under the heading, where the eye already is, and the
   portrait closes the right edge.

   Below 56rem it stacks in source order — portrait, then prose — because a
   180px circle beside a column narrower than a phone leaves both unreadable. */
.about {
  display: grid;
  gap: var(--s-5);
  align-items: start;
}

.about__photo {
  display: block;
  width: clamp(9rem, 24vw, 17rem);
  height: auto;
  aspect-ratio: 1;
  border: 1px solid var(--line);
  border-radius: 50%;
  object-fit: cover;
  justify-self: start;
  /* The portrait's own background is near black, so on the light theme it reads
     as a deliberate dark disc only if it has an edge; without the shadow it
     looks like a hole punched in the page. */
  box-shadow: var(--shadow-2);
}

@media (min-width: 56rem) {
  .about {
    grid-template-columns: minmax(0, 1fr) auto;
    gap: clamp(2rem, 5vw, 4rem);
  }

  .about__photo {
    grid-column: 2;
    grid-row: 1;
    justify-self: end;
  }

  .about .prose {
    grid-column: 1;
    grid-row: 1;
  }
}

/* --- 8. Pillars ---------------------------------------------------------- */

/* A hairline of light along the top edge: the cheapest way to make a flat
   panel read as a physical plate. Static, so it costs nothing per frame. */

/* The mark was the pillar's identifier: a generative SVG that encoded what the
   pillar does. The three cards are one line each since the architecture section
   absorbed them, so there is no room for a mark and the rule went with the markup:
   an unused rule is a promise to nobody. */

/* Two classes, not one: `.pillar p` is more specific than `.pillar__label` and
   was silently overriding the accent, so every label painted in the body
   colour. Measured: rgb(159,176,188) on all three pillars.
   The mix with --text is what keeps AA in both themes: --text is always the
   colour of maximum contrast against the page, so pulling 30% of it into the
   accent darkens it on light and lightens it on dark. Raw accents measured
   3.17-3.84 on light, which fails for 12.5px text. */

/* The way out of the card. Without this the pillars were a dead end: a reader
   who wanted the detail had to find it in the docs section below. */

/* --- 8b. Data voice -------------------------------------------------------- */

.figure {
  color: var(--text) !important;
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.16em;
  vertical-align: baseline;
}

.figure__value {
  display: inline-block;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 650;
  font-size: var(--figure-md);
  letter-spacing: 0;
  line-height: 1;
  color: var(--figure-color);
}

/* A figure that stands alone inside a table cell: the label sits directly under
   the number, and the cell is aligned to the top so a tall row doesn't float the
   figure down to the baseline. */
.figure--md {
  gap: 0.1em;
  vertical-align: top;
}

.figure--md .figure__value {
  font-size: var(--figure-md);
  letter-spacing: var(--figure-tracking);
  color: var(--figure-color);
}

.figure__label {
  font-family: var(--font-mono);
  font-size: var(--figure-label);
  font-weight: 600;
  letter-spacing: 0.12em;
  line-height: 1.25;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* The metrics band: the proof of work, right under the wordmark. */
.band {
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  background: var(--surface);
}

/* Above these widths the number of columns is stated. Left to auto-fit, the
   browser counts tracks against the minimum and then hands the free space to
   1fr, which can ask for more than the row has, and the band overflows. */

/* --- 9. Spec table ------------------------------------------------------- */

/* --- 10. Swarm lab ------------------------------------------------------- */

/* The DOM mirror of the swarm: one real button per node, so the same action the
   canvas answers to a pointer also exists for a keyboard and a screen reader. */

/* The legend. Colour never carries meaning alone: every key has a word and a
   line style, which is what the diagram encodes in the same two channels. */

/* --- 11. Growth narrative (M7) ------------------------------------------ */

/* The active step is marked with a rule that grows, never with a lower
   opacity: the text of an inactive step stays readable and AA. */

/* The two figures of a growth step share one grid, so the three steps stay
   vertically comparable while the reader scrubs them. */

/* --- 12. Terminal blocks ------------------------------------------------- */

.terminal {
  margin-top: var(--s-4);
  border: 1px solid var(--line);
  border-radius: var(--r-2);
  background: var(--surface-2);
  box-shadow: var(--shadow-2);
  overflow: hidden;
}

.terminal__body {
  padding: var(--s-4);
  overflow-x: auto;
  color: var(--text);
  white-space: pre;
}

/* A terminal that carries a whole verification run and needs the width: it is
   the only block on the page that may break out of the prose measure, because
   wrapping the output of `verify.sh` would destroy the columns that make it
   readable. */

@keyframes terminal-blink {
  0%,
  100% {
    opacity: 1;
  }

  50% {
    opacity: 0;
  }
}

/* --- 13. Steps ----------------------------------------------------------- */

.steps {
  margin-top: var(--s-4);
  padding-left: 1.5rem;
  display: grid;
  gap: 0.5rem;
  max-width: var(--measure);
  font-size: var(--step--1);
  color: var(--text-dim);
}

.steps li::marker {
  font-family: var(--font-mono);
  font-size: var(--step--2);
  color: var(--lime);
}

/* --- 14. Documentation links --------------------------------------------- */

/* The documentation header, and why it is not the home header.
 *
 * This page used to carry the wordmark and nothing else: no way back into the
 * site, no theme control, while every other page had both. That is a design
 * system failing at the one page a reader reaches from a search result rather
 * than from the home page, which makes it the first impression rather than the
 * last.
 *
 * It carries the breadcrumb and the theme switch but not the section nav: a
 * reader on this page wants the documents, and a menu of five anchors that all
 * leave the page is a way out, not a way around. The breadcrumb is the way out
 * and it says where they are, which is the job.
 *
 * The breadcrumb sits in the header rather than in the body because that is
 * where it belongs semantically — it describes the page's position in the site,
 * not an action within the document. Its previous form was a `btn--secondary`
 * under the lead, which read as "do this next" when it was "go back". */
.doc-header-nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3) var(--s-4);
  margin-inline-start: auto;
  /* Without this a flex item refuses to go below its content width, and the
   * header overflowed the viewport by 32px at 381px wide. It went unnoticed on
   * `/docs/`, whose breadcrumb is two short words; the fifteen document pages
   * put a full document title in the third slot, where it stops being lucky. */
  min-width: 0;
}

/* The primary navigation already pushes itself to the right edge on the home
   page and the project pages, where it is a direct child of the header. Inside
   this wrapper the whole group is already pushed, so a second `auto` here would
   split the free space and leave the breadcrumb floating in the middle. */
.doc-header-nav .primary-nav {
  margin-inline-start: 0;
}

.breadcrumb {
  min-width: 0;
}

.breadcrumb ol {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.45rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-family: var(--font-mono);
  font-size: var(--step--2);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.breadcrumb li + li::before {
  content: "/";
  margin-inline-end: 0.45rem;
  color: var(--line-hi);
}

.breadcrumb a {
  color: var(--text-dim);
  text-decoration: none;
}

.breadcrumb a:hover {
  color: var(--lime);
}

.breadcrumb [aria-current="page"] {
  color: var(--text);
}

/* The seven project pages carry the same breadcrumb the documentation does.
   It was named `doc-crumbs` while `/docs/` was its only caller, which is a name
   that lies the day a second page uses it. The component is a breadcrumb, so it
   is called one, and the three places that emit it were renamed with it rather
   than aliased: an alias would leave two names for one thing and the CSS gate
   would be right to complain about one of them. */
.proj-head .breadcrumb {
  margin-bottom: var(--s-3);
}

/* The table of contents, as a second column.
 *
 * The measurement is the argument for having it at all. The body is 1248px wide
 * at 1440 and the description column inside each row is 976px holding a single
 * short line: the width is not the problem, the length of the page is. Six
 * hundred lines of documents plus two prose sections come to about 3200px, and
 * nothing on the page said where anything was.
 *
 * So this is not the Stripe sidebar, which exists because Stripe has a hundred
 * pages that each need the same navigation repeated. There is one page here, and
 * a sidebar listing the fifteen links that sit three centimetres to its right
 * would be a duplicate, not a layout. What the page lacked was an index of
 * itself: the four groups and the two sections, which is what this is.
 *
 * It is sticky only where there is room for a column. Below that it becomes an
 * ordinary list above the lead, which is where a phone wants it. */
.doc-toc {
  margin: 0 0 var(--s-5);
  padding: var(--s-4);
  border: 1px solid var(--line);
  border-radius: var(--r-2);
  background: var(--surface);
}

.doc-toc__title {
  margin: 0 0 var(--s-3);
  font-family: var(--font-mono);
  font-size: var(--figure-label);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.doc-toc ol {
  display: grid;
  gap: 0.3rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--step--1);
}

.doc-toc__sections {
  margin-top: var(--s-3) !important;
  padding-top: var(--s-3) !important;
  border-top: 1px solid var(--line);
}

.doc-toc a {
  color: var(--text-dim);
  text-decoration: none;
}

.doc-toc a:hover {
  color: var(--lime);
}

@media (min-width: 64rem) {
  /* The grid goes on the body of the documentation section only. The two prose
     sections below keep their single column: they are reading, not an index, and
     a table of contents beside them would repeat the one in the first column. */
  #documentation .section__body {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 13rem;
    column-gap: var(--s-5);
    align-items: start;
  }

  #documentation .section__body > * {
    grid-column: 1;
    min-width: 0;
  }

  #documentation .section__body > .doc-toc {
    grid-column: 2;
    grid-row: 1 / span 20;
    position: sticky;
    top: 5rem;
    margin: 0;
    max-height: calc(100vh - 6rem);
    overflow-y: auto;
  }
}

.doclist__group {
  margin: var(--s-5) 0 var(--s-3);
  font-family: var(--font-mono);
  font-size: var(--figure-label);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.doclist__group:first-of-type {
  margin-top: 0;
}

.doclist {
  list-style: none;
  display: grid;
  gap: 0;
  border-top: 1px solid var(--line);
}

.doclist li {
  display: grid;
  gap: 0.15rem var(--s-4);
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--line);
}

@media (min-width: 48rem) {
  .doclist li {
    grid-template-columns: 16rem minmax(0, 1fr);
    align-items: baseline;
  }
}

.doclist a {
  position: relative;
  justify-self: start;
  color: var(--lime);
  text-decoration: none;
}

.doclist a:hover {
  color: var(--lime);
}

/* The marker that says what these links are.
 *
 * They are plain-text files, not pages of this site, and nothing on the page
 * said so: `architecture.md` reads the same whether it opens a document here or
 * downloads a binary. A reader had to click to find out, which on a page whose
 * whole job is choosing a document is the wrong order.
 *
 * Drawn in CSS rather than as fifteen inline SVGs: at about 60 bytes each the
 * icons would cost 900 of the 2 450 bytes this page has left, for a shape that
 * no screen reader was going to announce anyway. The `code` element already
 * carries the file name, so the glyph is decoration and is treated as such. */
.doclist a::before {
  content: "";
  display: inline-block;
  width: 0.6em;
  height: 0.78em;
  margin-inline-end: 0.45em;
  border: 1.5px solid currentColor;
  border-radius: 1px;
  vertical-align: -0.06em;
  opacity: 0.5;
}

.doclist span {
  color: var(--text-dim);
  font-size: var(--step--1);
}

/* --- A published document ------------------------------------------------ */
/*
 * The fifteen documents used to be served as `text/plain`, so these rules had
 * nothing to style. They are the repository's own notes, set for reading rather
 * than for selling: one column, a measure slightly wider than the marketing
 * pages because four documents are mostly tables, and no motion at all.
 *
 * The measure is 72ch and not `--measure` (64ch) on purpose. These documents
 * carry 378 table rows and 66 code blocks, and both of those are worse at 550px
 * than at 620px. The body text is still inside a comfortable line length; the
 * tables get `overflow-x` rather than being squeezed.
 *
 * Formulas are monospace source, not typeset maths. There are 48 across five
 * documents and the alternatives were KaTeX (about 280 KB, plus fonts, plus a
 * CSP that forbids inline styles today) or 48 hand conversions that drift. The
 * source is what the reader already sees in the `.md`, so this neither improves
 * nor degrades them, and nothing pretends to be typeset.
 */

.doc-article {
  width: min(100% - 2.5rem, 72ch);
  margin-inline: auto;
  padding-block: var(--s-6) var(--s-8);
}

.doc-article__head {
  padding-bottom: var(--s-5);
  margin-bottom: var(--s-5);
  border-bottom: 1px solid var(--line);
}

.doc-article__kicker {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--step--2);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--lime);
}

.doc-article__title {
  margin: var(--s-2) 0 0;
  font-size: var(--step-4);
  line-height: 1.15;
  letter-spacing: -0.015em;
  text-wrap: balance;
}

.doc-article__source {
  margin: var(--s-3) 0 0;
  font-size: var(--step--1);
  color: var(--text-dim);
  max-width: var(--measure);
}

.doc-body {
  font-size: var(--step-0);
}

.doc-body > * + * {
  margin-top: var(--s-4);
}

.doc-body h2,
.doc-body h3,
.doc-body h4,
.doc-body h5,
.doc-body h6 {
  margin-top: var(--s-6);
  line-height: 1.25;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

.doc-body h2 {
  font-size: var(--step-3);
  padding-bottom: 0.4rem;
  border-bottom: 1px solid var(--line);
}

.doc-body h3 {
  font-size: var(--step-2);
}

.doc-body h4,
.doc-body h5,
.doc-body h6 {
  font-size: var(--step-0);
  font-family: var(--font-mono);
  letter-spacing: 0;
}

.doc-body p {
  color: var(--text-dim);
}

/* An identifier is one unbreakable word, and a document is full of them:
 * `ThreadPoolExecutor(max_workers=…)` is 272px wide on a 265px column and pushed
 * the whole page 12px sideways at 320px, the only element on
 * `/docs/architecture/` to escape a scroll container. Breaking a name across
 * two lines is worse than nothing and much better than a page that scrolls. */
.doc-body code {
  overflow-wrap: anywhere;
}

.doc-body strong {
  color: var(--text);
}

.doc-body a {
  color: var(--lime);
  text-decoration: underline;
  text-underline-offset: 0.18em;
  text-decoration-thickness: 1px;
}

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

.doc-body a:focus-visible {
  outline: 2px solid var(--lime);
  outline-offset: 3px;
}

.doc-body ul,
.doc-body ol {
  padding-inline-start: 1.35rem;
  display: grid;
  gap: 0.35rem;
}

.doc-body li {
  color: var(--text-dim);
}

.doc-body li::marker {
  color: var(--lime);
}

.doc-quote {
  margin: 0;
  padding: var(--s-3) var(--s-4);
  border-inline-start: 2px solid var(--violet);
  border-radius: 0 var(--r-1) var(--r-1) 0;
  background: color-mix(in srgb, var(--surface) 92%, var(--violet) 8%);
}

.doc-quote p {
  margin: 0;
}

.doc-pre {
  padding: var(--s-3) var(--s-4);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-2);
  overflow-x: auto;
  line-height: 1.65;
}

.doc-pre--math {
  border-inline-start: 2px solid var(--violet);
  white-space: pre-wrap;
  color: var(--text);
}

.doc-math {
  font-family: var(--font-mono);
  font-size: 0.92em;
  color: var(--text);
  background: color-mix(in srgb, var(--surface-2) 88%, var(--violet) 12%);
  border: 1px solid var(--line);
  border-radius: var(--r-1);
  padding: 0.05em 0.32em;
}

.doc-rule {
  border: 0;
  border-top: 1px solid var(--line);
}

/* Tables scroll rather than shrink. A four-column table of measurements is
   unreadable at 320px whatever the font size, so the container takes the
   overflow and the table keeps its columns. */
.doc-table {
  overflow-x: auto;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-2);
}

.doc-table table {
  border-collapse: collapse;
  width: 100%;
  font-size: var(--step--1);
}

.doc-table th,
.doc-table td {
  padding: 0.5rem 0.7rem;
  text-align: start;
  vertical-align: top;
  border-bottom: 1px solid var(--line);
}

.doc-table thead th {
  background: var(--surface-2);
  font-family: var(--font-mono);
  font-size: var(--step--2);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text);
}

.doc-table tbody tr:last-child td {
  border-bottom: 0;
}

.doc-table code {
  font-size: 0.95em;
}

.doc-toc--page {
  margin-bottom: var(--s-5);
}

.doc-pager {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s-3);
  margin-top: var(--s-7);
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
}

.doc-pager__link {
  display: grid;
  gap: 0.2rem;
  padding: var(--s-3) var(--s-4);
  border: 1px solid var(--line);
  border-radius: var(--r-2);
  text-decoration: none;
  transition:
    border-color var(--d-2) var(--e-out),
    background-color var(--d-2) var(--e-out);
}

.doc-pager__link:hover {
  border-color: var(--line-hi);
  background: var(--surface);
}

.doc-pager__link:focus-visible {
  outline: 2px solid var(--lime);
  outline-offset: 3px;
}

.doc-pager__link--previous {
  grid-column: 1;
}

.doc-pager__link--next {
  grid-column: 2;
  text-align: end;
}

.doc-pager__label {
  font-family: var(--font-mono);
  font-size: var(--step--2);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.doc-pager__title {
  color: var(--text);
}

@media (max-width: 40rem) {
  .doc-pager {
    grid-template-columns: 1fr;
  }

  .doc-pager__link--next {
    grid-column: 1;
    text-align: start;
  }
}

/* --- 15. Limitations ----------------------------------------------------- */

.limits {
  list-style: none;
  display: grid;
  gap: var(--s-3);
}

.limit-card {
  padding: var(--s-4);
  border: 1px solid var(--line);
  border-left: 2px solid var(--violet);
  border-radius: var(--r-2);
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--surface) 93%, var(--violet) 7%),
    var(--surface) 45%
  );
  box-shadow: var(--shadow-1);
  transition:
    transform var(--d-2) var(--e-out),
    box-shadow var(--d-2) var(--e-out),
    border-color var(--d-2) var(--e-out);
}

.limit-card:hover {
  transform: translateY(-2px);
  border-color: color-mix(in srgb, var(--violet) 40%, var(--line));
  box-shadow: var(--shadow-2);
}

.limit-card h3 {
  font-size: var(--step-2);
  margin-bottom: 0.35rem;
}

.limit-card p {
  color: var(--text-dim);
  font-size: var(--step--1);
  line-height: 1.55;
}

/* --- 15b. Other projects ------------------------------------------------ */

/* One grid, twelve columns, six cards.
 *
 * It used to be two grids: the supporting pair in a `repeat(2, 1fr)` and the
 * four unpublished works nested inside it in their own auto-fit grid. Two grids
 * with the same container width do not share columns, so nothing lined up and
 * the second row could not be trusted to end where the first one did. Measured
 * at 1440 before this change: the support row used two of three tracks and left
 * a 443px hole on its right, and the row of four started and ended somewhere
 * else entirely. That is the asymmetry a reader sees and cannot name.
 *
 * A single 12-track grid fixes it by construction, because the only thing a row
 * can do now is add up to 12:
 *
 *   2 supporting cards x span 6 = 12   (two per row)
 *   4 mini cards      x span 3 = 12    (four per row)
 *
 * The paddings went too. `.projects` carried `padding-inline: clamp(1rem, 4vw,
 * 4rem)` from when this was a wide section that ran edge to edge, which put the
 * cards 57.6px inside the heading above them at 1440. The section body has its
 * own gutter now, so that was a second one: cards start on the same axis as the
 * `h2` at every width. */
.projects {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: var(--s-5);
  /* Cards sit at their own height instead of being stretched to the tallest in
     the row. See the note under `.project--band`. */
  align-items: start;
}

.project--support,
.project--mini {
  grid-column: span 12;
}

@media (min-width: 56rem) {
  /* Two supporting cards fill the row; the four mini cards go two by two, because
     four of them across a 728px container would be 164px each, under the 13rem
     floor a card needs to hold its stack. */
  .project--support,
  .project--mini {
    grid-column: span 6;
  }
}

/* A weave, two spans, three shapes.
 *
 * Measured at 1440 before this: both supporting cards were 612x460 with a 33.6px
 * name and a 4:3 plate, and all four mini cards were 294x534 with a 21.6px name
 * and the same plate. Six cards, two rectangles. The row maths caused it — a row
 * must add up to twelve, so four minis can only be 3+3+3+3 and two supports only
 * 6+6 — so width, height, plate and type size were all fixed by arithmetic.
 *
 * Span and shape are now separate decisions. Span weaves 8+4 / 4+8 / 8+4; shape
 * is how the plate sits in whatever width it got. Keeping them apart is what lets
 * the two wide mini cards differ: NEURAL BRICKS is wide and a panel, VIRAL_MFT is
 * wide and a strip. The first draft tied them together and produced two cards at
 * exactly 824x357; the `spread` gate caught it.
 *
 * `8`/`4` and not `7`/`5`: 4 columns is 400px at the 88rem wrap and 307px at
 * 64rem, both over the 280px floor the design set for a card at 1024; 3 columns
 * would be 269px and would break the twenty-character name. The weave starts
 * where the narrow card clears the floor, not where it looks best.
 *
 * Mini cards never get wider than a supporting one, so the pyramid still reads
 * flagship > support > mini: the tier is the treatment (no shadow, dim label, no
 * lift), not the width. */
@media (min-width: 64rem) {
  .project--wide {
    grid-column: span 8;
  }

  .project--narrow {
    grid-column: span 4;
  }
}

/* `max-height` decides how tall a plate paints, not `aspect-ratio`: a card is
   400px or 824px wide, so a 4:5 ratio asks for 500px or 1030px and is always
   cropped. What varies below is the cap — 7rem strip, 9rem panel, 12rem
   portrait — and the ratio only chooses how much of the picture survives it. */
.project--band .project__plate {
  aspect-ratio: 16 / 9;
  max-height: 7rem;
}

.project--panel .project__plate {
  aspect-ratio: 1 / 1;
  max-height: 9rem;
}

.project--portrait .project__plate {
  aspect-ratio: 4 / 5;
  max-height: 12rem;
}

/* A wide card is shorter than its neighbour — that is what makes it wide. With
   the grid's default stretch it carried the difference as dead space inside its
   own border: measured 164px on MESH_SILOS, 95 on VIRAL_MFT, 107 on NEURAL
   BRICKS, all below the last line of text. Empty space inside a bordered box
   reads as a card that failed to load; the same space outside reads as
   composition. Hence `align-items: start` on `.projects`: each card ends where
   its content ends and the row is still as tall as its tallest.

   `align-content: space-between` was the alternative and is worse — it spreads
   label, name and claim until a wide card's title floats away from its own
   sentence. */

/* The name, carried by a token rather than by an override per combination: a
   card can be small *and* wide, so size cannot follow the tier alone.
   54.4 / 33.6 / 24 / 21.6 is a 2.52x range from the flagship down. */
.project--flagship {
  --project-title: var(--step-5);
}

.project--support.project--band {
  --project-title: var(--step-4);
}

.project--support.project--portrait {
  --project-title: var(--step-3);
}

.project--mini.project--band {
  --project-title: var(--step-3);
}

.project {
  position: relative;
  display: grid;
  gap: var(--s-3);
  align-content: start;
  /* A grid item's min-width defaults to auto, which is min-content: one long
     unbroken token inside a card then sets the floor for the whole track and the
     card spills instead of the word wrapping. Zero here and on the title is what
     makes `overflow-wrap` do its job at all. */
  min-width: 0;
  /* One token for the card's inner gutter, because the plate bled by a
     hard-coded `--s-5` and the mini cards only have `--s-4` of padding.
     Measured before this: every mini card's plate was 308px wide on a 294px
     card, hanging 7px past both borders, with `overflow: visible` on the card
     so nothing clipped it. The three plates that read as deliberate bled
     correctly; the four that read as a mistake were the ones nobody looked at. */
  --project-pad: var(--s-5);
  padding: var(--project-pad);
  border: 1px solid var(--line);
  border-radius: var(--r-3);
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--surface) 90%, var(--accent, var(--cyan)) 10%),
    var(--surface) 46%
  );
  box-shadow: var(--shadow-2);
  transition:
    transform var(--d-2) var(--e-out),
    box-shadow var(--d-2) var(--e-out),
    border-color var(--d-2) var(--e-out);
}

.project::after {
  content: "";
  position: absolute;
  top: 0;
  left: var(--s-5);
  right: var(--s-5);
  height: 1px;
  background: linear-gradient(
    90deg,
    transparent,
    var(--line-hi) 20%,
    transparent 80%
  );
  pointer-events: none;
}

.project:hover {
  transform: translateY(-3px);
  box-shadow: var(--shadow-3);
  border-color: color-mix(in srgb, var(--accent, var(--cyan)) 40%, var(--line));
}

.project--silos {
  --accent: var(--cyan);
}

.project--hecate {
  --accent: var(--lime);
}

/* Third project, third accent. The three colours already exist in the palette, so
   this adds no new value: what it adds is one more way to tell the cards apart at
   a glance without reading their titles. */
.project--tardeo {
  --accent: var(--violet);
}

/* The tiers are a hierarchy, so the grid has to show one. The flagship takes the
   whole row at every breakpoint; the supporting pair share the next; the works
   that are not published sit in their own row below, quieter, with no shadow and
   no action, because there is nothing to click.
   `1 / -1` rather than a column count: the same rule holds for one, two and three
   tracks, which is what the three breakpoints above produce. */
.project--flagship {
  grid-column: 1 / -1;
}

/* The text half of the flagship card. It is a grid of its own so that the label,
   the name, the claim and the sentence keep the same rhythm they have in the
   single-column cards, without inheriting the flagship's two-track layout. */
.project__main {
  display: grid;
  gap: var(--s-3);
  align-content: start;
}

/* A full-row card with one column of text leaves half the row empty, which reads
   as a missing card rather than as a hierarchy. Two columns at the width where
   the row is wide enough to split, one below: `minmax(0, …)` on both sides so a
   long unbroken token can shrink a column instead of overflowing the card. */
@media (min-width: 56rem) {
  .project--flagship {
    grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
    column-gap: var(--s-6);
    align-items: start;
  }

  .project__meta {
    display: grid;
    gap: var(--s-3);
  }
}

/* `POC_RETAIL_ANALYTICS` is one token of twenty characters, in mono at the
   card's title size. Measured at 1440 before this: the four-across tier gave each
   card 262px of inner width and the word needed 383px, so three of the four
   titles hung 25 to 121px outside their own border and iteration 22's
   `overflow-wrap: anywhere` split them mid-token, reading
   `POC_RETAIL_ANA / LYTICS`. (The other long title was `RSVP_AI_READING`, since
   renamed to `NEURAL BRICKS`; the twenty-character case is now the only one, and
   it is the one the fix has to hold for.)
 *
 * Two things had to change, and the second is the one that is easy to miss.
 *
 * The size: `--step-4` is 33.6px here, sized for the flagship's full-width card,
 * and twenty mono characters at that size are 383px. `--step-2` puts them at
 * 246px, inside 262. A smaller name is also what this tier is for: it is the
 * quiet one, below two supporting cards, and its label is already dimmed for the
 * same reason.
 *
 * The width: `break-word` allows a break only when a line box is already too
 * narrow, so it does **not** reduce the element's min-content size the way
 * `anywhere` does. As a grid item, the title therefore kept a min-content of
 * 383px and pushed out of the card instead of wrapping. `min-width: 0` lets it
 * shrink to the track; only then does `break-word` have anything to do. */
.project--mini .project__title {
  letter-spacing: 0;
  overflow-wrap: break-word;
  hyphens: auto;
  min-width: 0;
}

@media (max-width: 40rem) {
  /* At 320px the inner width is about 248px and twenty characters at `--step-2`
     need 200, so the name fits without a single break. The shape rules keep
     their own size here because a wide mini at 320px is still a full-width
     card, and 24px fits twenty characters inside 248px. */
  .project--mini.project--band {
    --project-title: var(--step-2);
  }
}

.project--mini {
  --project-pad: var(--s-4);
  /* The quietest tier, and the fallback for every shape below it: a mini card
     that is also a band is the one exception and sets its own size above. */
  --project-title: var(--step-2);
  box-shadow: none;
  gap: var(--s-2);
}

/* The label is the loudest thing in a card, and these cards should not be. The
   accent stays for the tier above: here the colour is the one the page uses for
   secondary text, so the three unpublished works read as a footnote to the two
   above them without being hidden. */
.project--mini .project__label {
  color: var(--text-dim);
}

/* A card with no link should not lift or brighten: the movement and the accent
   border are an affordance, and a card that asks to be pressed and does nothing
   is the small dishonesty this section is not allowed to make. */
.project--mini:hover {
  transform: none;
  box-shadow: none;
  border-color: var(--line);
}

/* A card that can be opened.
 *
 * `<details>` is the only interactive control on this site that costs no script:
 * the browser owns the state, the keyboard behaviour and the disclosure
 * semantics, and a reader with JavaScript off gets exactly the same thing. That
 * is why this and not a custom panel.
 *
 * The summary is styled as a small label rather than a control: the cards are
 * already dense, and a button-shaped affordance inside a card that is itself a
 * link reads as two competing actions. The marker is replaced by a chevron in
 * the same mono face as the labels above it. */
.project__details {
  margin-top: var(--s-3);
  border-top: 1px solid var(--line);
  padding-top: var(--s-3);
  font-size: var(--step--2);
}

.project__details > summary {
  cursor: pointer;
  font-family: var(--font-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
  /* `list-style: none` drops the native triangle in every engine that matters,
     and the two vendor-prefixed display values drop it in the two that keep
     their own. Without this the marker sits outside the padding box and the
     label no longer lines up with the stack above it. */
  list-style: none;
  display: list-item;
}

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

.project__details > summary::before {
  content: "+";
  display: inline-block;
  width: 1em;
  color: var(--accent, var(--cyan));
}

.project__details[open] > summary::before {
  content: "−";
}

.project__details > summary:hover {
  color: var(--text);
}

/* `[open]` is not decoration here. The browser hides the children of a closed
   `<details>` with its own `display: none`, and an author rule setting `display`
   on those children outranks it: without the attribute selector the list inside
   a closed disclosure stays on the page and the control does nothing. The
   keyboard audit caught it as an unreachable link, which is exactly what it was,
   visible but inside a closed disclosure. */
.project__details[open] > ul {
  margin: 0.55rem 0 0;
  padding: 0 0 0 1em;
  list-style: none;
  display: grid;
  gap: 0.3rem;
  color: var(--text-dim);
}

.project__details > ul > li::before {
  content: "·";
  margin-right: 0.45em;
  color: var(--line-hi);
}

/* The arrow. It is the one affordance on these cards that says "there is a page
   behind this", so it only appears on the pointer, and it slides rather than
   fades: an arrow that appears in place reads as decoration.

   The timing is written here rather than taken from `--d-2`, deliberately.
   At 240ms the arrow is already in place before the eye has finished the
   movement that caused it, and at 480ms (`--d-3`) it reads as a delay. 340ms
   on `--e-out` is the window where a hover is legible as a decision. The
   travel is 0.4em so the slide is visible at the same distance the glyph
   occupies; 0.25em moved too little to be sure it had moved at all. */
.project__title a::after {
  content: "→";
  display: inline-block;
  margin-left: 0.4em;
  opacity: 0;
  transform: translateX(-0.4em);
  transition:
    opacity 340ms var(--e-out),
    transform 340ms var(--e-out);
}

.project:hover .project__title a::after,
.project:focus-within .project__title a::after {
  opacity: 0.75;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .project__title a::after {
    transition: none;
    transform: none;
  }
}

/* The plate answers the pointer the way the card does — a little more light,
   no movement — so a card is bright when it is pointed at without ever looking
   like it moved. 620ms is slower than the arrow on purpose: two things that
   arrive together read as a single gesture, and a lift and a highlight are
   not one. `focus-within` is here for the same reason it is on the arrow: the
   keyboard should get the same answer as the pointer. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .project__plate {
    transition: filter 620ms var(--e-out);
  }

  .project:hover .project__plate,
  .project:focus-within .project__plate {
    filter: brightness(1.18) saturate(1.06);
  }
}

/* The illustration, bled to the card's edges.
 *
 * The cards used to carry a small generative mark instead — three or four shapes
 * at 3.8rem encoding what the project does. It was a good identifier and a poor
 * portfolio: seven cards of type on a dark ground, with nothing to look at, is
 * the thing this page was accused of being. Each card now shows the same plate
 * its own page shows.
 *
 * The negative margins are what make it read as a plate rather than as a picture
 * dropped inside the padding box: pulling the image out by exactly the card's own
 * padding puts its corners on the border radius. It reads `--project-pad` and not
 * `--s-5` because the two tiers have different gutters, and using one number for
 * both is what made every mini plate overhang its card by 7px a side.
 *
 * `max-height` is doing real work, and since iteration 38 it is the only thing
 * that does: the card is 400px or 824px wide, so no realistic `aspect-ratio`
 * survives the cap. The three shape rules above set the cap; this line is the
 * fallback for a card with no shape class. */
.project__plate {
  display: block;
  width: calc(100% + 2 * var(--project-pad));
  margin: calc(-1 * var(--project-pad)) calc(-1 * var(--project-pad)) var(--s-1);
  aspect-ratio: 4 / 3;
  max-height: 11rem;
  object-fit: cover;
  object-position: center;
  border-radius: calc(var(--r-3) - 1px) calc(var(--r-3) - 1px) 0 0;
  border-bottom: 1px solid var(--line);
  /* The plates are near-black and the site has a light theme. A hairline keeps
     the plate reading as a deliberate block instead of as a hole in the card. */
  background: var(--ink);
}

/* The stack as chips. It used to be a fact row with a label, which costs about
   sixty bytes per card and reads like a spec sheet; a private project that is not
   published should read as a closed door with a nameplate, not as a datasheet.
   The chips are words and nothing else, so they survive a screen reader as a list
   of five items. */
.project__stack {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.project__stack li {
  padding: 0.12rem 0.45rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: var(--step--3);
  letter-spacing: 0.04em;
  color: var(--text-dim);
  white-space: nowrap;
}

.project__label {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--figure-label);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent, var(--cyan));
}

.project__title {
  margin: 0;
  font-family: var(--font-mono);
  /* Measured in a card at 1440px: the name was 24px and its hook 21.6px, a
     1.11x step, so "HECATE" and the sentence under it read as one grey block.
     Raising the name to 33.6px makes the step 1.56x and leaves the hook at the
     size that makes it worth reading. The name is mono and tracked, so it can
     carry the height without looking shouted.

     Since iteration 38 the size comes from the card, because a card can be small
     *and* wide: `--project-title` is set next to the shape rules above and the
     fallback is the supporting card's size, which is what an unclassed card
     should look like. 54.4px on the flagship, 33.6 on a wide supporting card,
     24 on a narrow one or a wide mini, 21.6 on the quietest. */
  font-size: var(--project-title, var(--step-4));
  letter-spacing: 0.02em;
}

.project__claim {
  margin: 0;
  font-size: var(--step-2);
  line-height: 1.35;
  color: var(--text);
}

.project__body {
  margin: 0;
  font-size: var(--step--1);
  line-height: 1.6;
  color: var(--text-dim);
}

.project__body strong {
  color: var(--text);
  font-weight: 600;
}

.project__facts {
  display: grid;
  gap: 0.45rem;
  margin: 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--line);
  padding-top: var(--s-3);
  font-size: var(--step--2);
  line-height: 1.55;
  color: var(--text-dim);
}

.project__fact-label {
  display: inline-block;
  min-width: 7.5rem;
  font-family: var(--font-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text);
}

.project__actions {
  margin: 0;
  padding-top: var(--s-2);
}

/* --- 16. Error page ------------------------------------------------------ */

.error__title {
  font-size: var(--step-5);
  letter-spacing: -0.02em;
  margin-bottom: var(--s-3);
}

/* --- 17b. The "now" block ------------------------------------------------ */

/* What is happening, under the hero and above everything that explains. It is a
   `div` and not a `section` on purpose: it is not part of the page outline, it is
   a caption on the present, and a section would have shifted the numbered
   sequence and the copy deck with it. */
.now {
  display: grid;
  gap: var(--s-2);
  margin: var(--s-6) auto 0;
  padding-top: var(--s-4);
  border-top: 1px solid var(--line);
  max-width: 62ch;
}

.now__label {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--figure-label);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--color-accent, var(--cyan));
}

.now__list {
  display: grid;
  gap: 0.3rem;
  margin: 0;
  padding-left: 1.1rem;
  font-size: var(--step--1);
  color: var(--text-dim);
}

.now__list li::marker {
  color: var(--line-hi);
}

.now__list strong {
  color: var(--text);
  font-weight: 650;
}

/* --- 17. Footer ---------------------------------------------------------- */

.site-footer {
  border-top: 1px solid var(--line);
  background: var(--ink-2);
  padding-block: var(--s-6) var(--s-7);
  margin-top: var(--s-7);
}

.site-footer__inner {
  display: grid;
  gap: var(--s-3);
}

.site-footer__license {
  font-family: var(--font-mono);
  font-size: var(--step--1);
  color: var(--text);
}

.site-footer__artifacts {
  max-width: 62ch;
  font-size: var(--step--1);
  color: var(--text-dim);
}

.site-footer__links ul {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1.25rem;
  list-style: none;
  font-size: var(--step--1);
}

.site-footer__note {
  font-size: var(--step--2);
  color: var(--text-dim);
}

/* The colophon. It carries the author's name with its accent, which the language
   gate allows through `PROPER_NOUNS` because an accent in a surname is spelling,
   not a language. The links are `rel="noopener"` because `security-check` demands
   it of every external link, including ones that never open a tab. */
.site-footer__author {
  margin: 0;
  font-size: var(--step--2);
  color: var(--text-dim);
}

.site-footer__author a {
  color: var(--text);
  text-decoration-color: var(--line);
  text-underline-offset: 0.18em;
}

.site-footer__author a:hover {
  text-decoration-color: var(--color-accent, var(--cyan));
}

/* --- 18. Copy confirmation ----------------------------------------------- */

.copy-toast {
  position: fixed;
  left: 50%;
  bottom: 1.5rem;
  z-index: var(--z-overlay);
  transform: translate(-50%, 1rem);
  padding: 0.45rem 0.9rem;
  border: 1px solid var(--lime);
  border-radius: var(--r-full);
  background: var(--ink-2);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: var(--step--2);
  opacity: 0;
  pointer-events: none;
  transition:
    opacity var(--d-2) var(--e-out),
    transform var(--d-2) var(--e-out);
}

.copy-toast[data-visible="true"] {
  opacity: 1;
  transform: translate(-50%, 0);
}

/* --- 19. Motion ---------------------------------------------------------- */

/* M9 · hover micro-interactions.
   Pure CSS on purpose: a pointer that cannot hover never triggers them, and
   the reduced motion query removes every transition at once. The lift itself
   lives with the component; here only the sweep is added, on ::before, so the
   static top hairline in ::after survives. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .doclist a::before {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: -4px;
    height: 1px;
    background: var(--lime);
    transform: scaleX(0);
    transform-origin: 0 50%;
    transition: transform var(--d-1) var(--e-out);
  }

  .doclist a:hover::before {
    transform: scaleX(1);
  }

  .doclist a::after {
    content: "→";
    display: inline-block;
    margin-left: 0.4em;
    transition: transform var(--d-1) var(--e-out);
  }

  .doclist a:hover::after {
    transform: translateX(3px);
  }
}

@media (prefers-reduced-motion: no-preference) {
  .js .hero__title {
    clip-path: inset(0 0 110% 0);
  }

  /* Opacity and transform only. A blur here would stay on after the tween: the
     module animates opacity and y, so nothing would ever reset the filter, and
     four full-width blurred text blocks repaint on every scroll frame. */
  .js .hero__claim,
  .js .hero__sub,
  .js .hero__meta,
  .js .hero__actions {
    opacity: 0;
    transform: translateY(18px);
  }

  /* Scoped to `data-page="home"` on purpose. The reveal is an effect of the home
   page, which loads the bundle that runs it; `/docs` shares this stylesheet and
   the same `js` bootstrap (one hash covers both pages) but loads no script, so a
   rule keyed only on `.js` hid the document list and the limitation cards and
   nothing ever brought them back. The page attribute is in the markup, so the
   hiding cannot outrun the thing that undoes it. */
  .js [data-page="home"] .terminal,
  .js [data-page="home"] .steps,
  .js [data-page="home"] .doclist li,
  .js [data-page="home"] .limit-card {
    opacity: 0;
    transform: translateY(26px);
  }

  /* The numeral rises out of its own baseline: a mask, not a fade, so the
     entrance has a direction that matches the reading order. */
  .js .section__number {
    clip-path: inset(0 0 100% 0);
  }

  /* The metrics band is deliberately not revealed: it is the second thing the
     reader lands on, and hiding it delays the largest paint of the page. */

  .js #growth-expert-lane,
  .js #growth-replica-badge {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

@media (prefers-reduced-transparency: reduce) {
  .site-header {
    background: var(--ink-2);
    backdrop-filter: none;
  }
}

@media (forced-colors: active) {
  /* Background-clip text paints nothing under forced colours, so both the
     wordmark and the section numerals fall back to the system ink. */
  .hero__title,
  .section__number {
    color: CanvasText;
    -webkit-text-fill-color: CanvasText;
    background-image: none;
  }

  .limit-card,
  .terminal {
    border: 1px solid CanvasText;
  }

  .btn--primary {
    border: 1px solid CanvasText;
  }
}

/* --- 20. Input devices --------------------------------------------------- */

/* A finger needs a real 44x44px target. That includes inline, documentation
   and footer links, not only the obvious buttons. The mouse density is left
   unchanged, and the theme switch gives its inner padding back to the header. */
@media (pointer: coarse) {
  a,
  button {
    min-width: 44px;
    min-height: 44px;
  }

  a {
    display: inline-flex;
    align-items: center;
  }

  /* A 44px control needs a tighter header to stay under 70px: with the mouse
     padding kept, the touch bar would grow to 71px on a wide screen. */
  .site-header__inner {
    padding-block: 0.5rem;
  }

  /* The segmented switch gives its frame back to the header so three real
     44x44px targets still fit the compact row at 320px. The label stays
     centred in each segment instead of hugging the top edge. */
  .theme-switch {
    padding: 0;
    gap: 1px;
  }

  .theme-switch button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    min-height: 44px;
    padding-inline: 0.6rem;
  }

  .primary-nav a {
    display: flex;
    align-items: center;
  }

  .doclist a {
    max-width: 100%;
    overflow-wrap: anywhere;
  }
}

/* A touch screen fires :hover on tap and keeps it until the next tap, so the
   lifted button and the growing underline stay stuck on a control the reader
   has already used. Where there is no hover, the interactive feedback is the
   :active state alone. */
@media (hover: none) {
  /* A tap leaves :hover stuck until the next tap, so the lift and the sweep
     must not survive a finger. */

  .limit-card:hover {
    transform: none;
    box-shadow: var(--shadow-1);
  }

  .btn:hover {
    transform: none;
    border-color: var(--line-hi);
    color: var(--text);
  }

  .btn--primary:hover {
    border-color: var(--lime);
    color: var(--ink);
    box-shadow: none;
  }

  .btn:active {
    border-color: var(--lime);
    color: var(--lime);
  }

  .btn--primary:active {
    color: var(--ink);
  }

  .primary-nav a:hover::after {
    transform: scaleX(0);
  }

  .primary-nav a:hover {
    color: var(--text-dim);
  }

  .primary-nav a[aria-current="true"] {
    color: var(--text);
  }
}

/* --- 16. First screen on a phone ----------------------------------------- */

/* The hero was 645px of a 740px viewport at 360px wide, so the metrics band
   started at 706px: none of the five figures was on the first screen, and the
   band is meant to be the second thing the eye lands on. These rules live at
   the end of the file on purpose. An earlier attempt put them next to the hero
   block, above the `.hero__meta` and `.band__*` definitions, so the cascade
   silently discarded them and the measured result did not change at all.
   Nothing is hidden: the trims are padding, row gaps, the meta font size and
   the figure size (48px -> 40px, still the largest type on the page). */
@media (max-width: 47.99rem) {
  .hero__inner {
    padding-block: var(--s-5);
    gap: var(--s-2);
  }

  .hero__sub {
    font-size: var(--step-0);
    line-height: 1.45;
  }
}

/* Below 30rem the meta row wraps onto a second line and used to cost the height
   the band needed for its first value and label, so it was dropped to 0.7rem
   (11.2px) to stay on one line. That trade is no longer needed: after the hero
   was re-composed the call to action ends 114px above the fold at 360x740, and
   the four items total 291px of a 305px row at 14px. Measured at 14px they
   still fit on one line, so the row keeps the type size it has everywhere else
   instead of shrinking below the floor. */
@media (max-width: 29.99rem) {
  .hero__meta {
    gap: 0.15rem 0.7rem;
    font-size: 0.8125rem;
  }
}

/* --- 21. Pillars in depth ------------------------------------------------ */

/* The cards were flat plates with a shadow. Perspective plus one plate behind
   each card is enough to make them read as objects, and it costs no JavaScript:
   the browser composites the transform on the GPU. Three rules carry the whole
   effect, and every one of them is opt-out able.

   Order matters here. `(hover: none)` earlier in this file sets
   `.pillar:hover { transform: none }` so a tap does not leave the card stuck in
   the air; a later `.pillar:hover` with the same specificity would undo that.
   The lift therefore lives inside `(hover: hover)`, and it also stops under
   reduced motion, where the brief asks for the border change alone. */

/* The depth layer: the pillar's own accent, graded to nothing, sitting a little
   lower and further back. At rest it only peeks below the card, which is the
   rim that reads as thickness. */

/* Forced colours flatten everything: an accent tinted plate becomes a coloured
   rectangle with no text, so it is dropped rather than shown wrong. */

/* The poster stands in for the canvas when JavaScript is off. It is the same
   twelve slabs, projected once by hand, so the section still shows the topology
   instead of an empty rectangle. Placed on the copy side of the hero at wide
   widths for the same reason the WebGL scene is shifted right. */
.hero__poster {
  position: absolute;
  inset: 0;
  z-index: var(--z-canvas);
  width: 100%;
  height: 100%;
  opacity: 0.55;
  pointer-events: none;
}

@media (min-width: 56rem) {
  .hero__poster {
    left: 48%;
    width: 52%;
  }
}

/* --- 22. Architecture, drawn --------------------------------------------- */

/* The section used to be a fourteen-row table followed by a chip map that
   repeated the same names as text. Two representations of one fact, and neither
   showed the thing that matters: which plane owns which module, and that both
   planes read one contract. The diagram replaces the chip map, and the table
   moves into <details> where it stays complete, copyable and indexable.

   The nodes carry `data-plane-block`, the same attribute the highlighter already
   used, so the existing scroll and pointer pairing keeps working with no new
   JavaScript. */

/* Active is the same class the scroller and the pointer already toggle, so a
   module lit by the table and a module lit by the diagram look identical. */

/* A focus indicator that is actually visible. The first version of this rule
   said `outline: none` and leaned on the rect going from a 1px to a 2px stroke,
   which is barely perceptible and is not a focus ring by any reading: the
   keyboard audit reported focus as invisible on every one of the fourteen
   nodes. The outline is restored on the group, and the rect keeps the thicker
   stroke so the node still reads as active in engines that do not paint an
   outline on SVG. */

/* A row that the diagram has singled out. The attribute is set whether or not
   the disclosure is open, so opening the table later shows the row already
   marked instead of losing the connection between the two views. */
tr[data-lit="true"] {
  background: color-mix(in srgb, var(--cyan) 12%, transparent);
}

tr[data-lit="true"] th,
tr[data-lit="true"] td {
  color: var(--text);
}

tr[data-lit="true"] th::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--cyan);
}

tr[data-lit="true"] th {
  position: relative;
}

@media (forced-colors: active) {
  tr[data-lit="true"] {
    outline: 2px solid CanvasText;
    outline-offset: -2px;
    background: none;
  }
}

/* --- 22. The pillars as an editorial spread ------------------------------ */

/* Three equal cards is a grid, and a grid says "these are three items of the
   same weight". They are not. Pillar A carries the claim the whole page rests
   on — several whole copies of the model running at once — and B and C are the
   two consequences of it. So the lead takes two thirds of the width and the
   other two stack beside it, which is how a spread is set and how the eye is
   told what to read first.

   Below 48rem there is no room for a second column and the three stack in
   source order, which is also the reading order, so nothing has to be
   reordered for the phone. */

/* The two secondary pillars sit closer to each other than to the lead, so the
   spread reads as one block on the left and a column on the right. */

/* --- 23. The test split and the gates ------------------------------ */

/* The two test figures are one measurement cut in two, so they get a proportion
   and a denominator instead of sitting side by side as two totals. Rust keeps
   the cyan of the control plane and Python the lime of the data plane, which is
   the legend the architecture diagram above already uses.

   Both rows are hidden in the markup and shown by the module that fills them,
   so a reader without JavaScript never sees an empty bar pretending to be a
   measurement. Nothing is lost with JavaScript off: both figures and both labels
   are in the document. */

/* `flex-grow` carries the ratio, so the two parts are exact by construction: the
   widths are the numbers themselves, not percentages rounded to a pixel. */

/* The strip is the composition of the run, not its length: 7 + 5 + 6 is 18, and
   the 5 in the middle are the ones SKIP_RUST=1 drops, which is where 13 comes
   from. The dash pattern draws one mark per gate and the x2 of each line sets the
   count, so the drawing is the number. Colour follows the vocabulary the test
   split already uses: cyan is Rust, lime is Python. Nothing is dimmed to say
   "optional": the caption spells that out, so no meaning is carried by an opacity
   that a light theme would wash out. */

/* The names are the same facts as the strip, spelled out. They were always
   visible; closing them is what makes the strip the thing you read first instead
   of a caption above a wall of twenty-two items. */

/* --- 24. Gates as a list -------------------------------------------- */

/* Twenty-one gates used to be one paragraph with twenty-one inline <code>. The
   same facts as an ordered list are scanable, and the list carries the count, so
   a screen reader announces "list of 21 items" instead of reading a wall. Two
   columns on a wide screen, one on a phone, with the numbers in the mono face so
   the column of digits reads as a column. */

/* The one picture in a section that is otherwise a column of numbers. It is
   drawn at a fixed aspect and capped by the reading measure, so it never
   becomes decoration: 300 units wide at 34 tall reads as a strip, not an
   illustration. Colour comes from currentColor, so both themes are covered by
   the text colour and there is no second rule to keep in step. */

/* The bracket that grouped three equal projects and claimed nothing about size is
   gone: the projects are a pyramid now, so "equal" would be the wrong sentence and
   a drawing cannot be right about a hierarchy it does not show. Its rule went with
   it, and the flagship card carries the hierarchy instead. */

/* A field of nodes under a ceiling: the ceiling is the section, the field is
   what it bounds. Forty <circle> elements would cost about forty times this;
   one dashed path with round caps draws the same dots, which is the whole
   difference between a texture and a chart. */
.limits__field {
  display: block;
  width: min(100%, 10rem);
  height: auto;
  margin: 0;
  color: var(--text-dim);
  overflow: visible;
}

/* One line at the foot of a section that points at the page holding the rest.
   It reads as a continuation, not as a call to action: same size as the prose it
   follows, and the accent only on the link. */

/* --- 25. The footer's one gesture ----------------------------------- */

/* A last statement at the scale of a statement, not of a link. The dot breathes
   at 0.4 Hz, which is the rhythm the hero lattice rests at: the same pulse at the
   top and the bottom, and the only thing that moves down here. */
.site-footer__cta-line {
  margin: 0 0 var(--s-4);
}

.site-footer__cta {
  display: inline-flex;
  align-items: center;
  gap: 0.7rem;
  font-family: var(--font-display);
  font-size: var(--step-4);
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.1;
  color: var(--text);
  text-decoration: none;
  background-image: linear-gradient(currentColor, currentColor);
  background-size: 0 1px;
  background-position: 0 100%;
  background-repeat: no-repeat;
  transition:
    background-size var(--d-3) var(--e-out),
    color var(--d-2) var(--e-out);
}

.site-footer__cta:hover,
.site-footer__cta:focus-visible {
  background-size: 100% 1px;
  color: var(--lime);
}

.site-footer__cta-dot {
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: var(--lime);
  /* Three pulses and stop, not three hundred. The hero already owns the infinite
     gesture (the lattice breathes at 0.4 Hz by design); a second endless pulse
     down here competes with it and reads as decoration rather than as one last
     signal. Finite is the whole point: a gesture, not a heartbeat. */
  animation: footer-breathe 2.5s ease-in-out 3;
}

@keyframes footer-breathe {
  0%,
  100% {
    opacity: 0.45;
  }

  50% {
    opacity: 1;
  }
}

/* The breath is the only motion in the footer, so it is the only thing that has
   to stop. It stops at full opacity rather than at zero, so the dot is still a
   dot when nothing moves. */
@media (prefers-reduced-motion: reduce) {
  .site-footer__cta-dot {
    animation: none;
    opacity: 1;
  }

  .site-footer__cta {
    transition: none;
  }
}

/* --- 26. The lab's act panel --------------------------------------- */

/* What a node is, in words, beside the picture that shows it. Set as a margin
   note: a rule on the left, a title in the display face, two short paragraphs in
   the body face. The panel is focusable so the arrow keys can move between the
   nodes, and it takes focus ring like any other control. */

/* --- 27. Line breaking ------------------------------------------------- */

/* Measured against the reference sites, the one axis where this page is doing
   something they are not is text: 2 440 words in the body against 239, 308 and
   749. That is not a thing to hide, so it is the thing to set well.

   `balance` on the display face stops a two-word orphan from sitting alone on
   the second line of a heading, which is the most visible flaw a big typeface can
   have. `pretty` on prose does the same at paragraph scale by refusing to leave a
   short last line. Neither changes a single word, and both are pure hints: a
   browser that does not know them lays the text out exactly as before. */
.hero__title,
.section__title,
.site-footer__cta {
  text-wrap: balance;
}

.prose p,
.steps li,
.limit-card p,
.project p {
  text-wrap: pretty;
}

/* --- 28. Links that answer -------------------------------- */

/* Prose links used to change colour and nothing else, which on a page whose main
   material is prose is the one place a link should feel like an object. The
   underline thickens rather than appearing: the line is already there, so nothing
   moves and nothing is added. Both properties animate, and the whole rule is off
   under reduced motion because a thickening line is still motion. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .prose a,
  .limit-card a,
  .site-footer__artifacts a {
    transition:
      color var(--d-2) var(--e-out),
      text-decoration-thickness var(--d-2) var(--e-out);
  }

  .prose a:hover,
  .limit-card a:hover,
  .site-footer__artifacts a:hover {
    text-decoration-thickness: 2px;
  }
}

/* On focus the underline stays thin but the offset opens, so the keyboard gets a
   different signal from the pointer and the two never look like the same state. */
.prose a:focus-visible,
.limit-card a:focus-visible,
.site-footer__artifacts a:focus-visible {
  text-underline-offset: 0.32em;
  text-decoration-thickness: 2px;
}

/* --- 29. Motion, owned by the browser -------------------------------------

   The page reads as static because its motion was written for sections that
   no longer exist: the reveal targets were `.pillar`, `.arch`, `.lab` and
   `.terminal`, and the four of them are gone. What is left is text and
   cards, and nothing moved.

   So the motion is redone with what the platform offers and nothing else:
   a scroll timeline for the reading bar, a view timeline for the reveals, and
   the View Transitions API for the move between pages. No library is added to
   do it, and the one library that was already here is not asked to do more.

   Every rule below is inside `prefers-reduced-motion: no-preference`, so a
   reader who asks for less motion gets a page that never moves. */

.top-anchor {
  /* The target of the brand, which says it goes back to the top.
     It used to be the sticky header, and a sticky element is always at the
     top of the viewport: the browser resolved the anchor against where the
     header was *painted*, not where it sat in the document, so the click
     scrolled up by exactly the scroll padding and stopped. Measured: from
     2000 the brand landed on 1904, from 3004 on 2908. A zero-height
     sentinel at the top of the document is the honest target: the browser
     resolves it to 0 and the reader gets the top. */
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  pointer-events: none;
}

/* The way back up, for a reader who has scrolled past the hero. It is hidden
   from the tab order while it is hidden, so the keyboard never lands on a
   control that cannot be seen. */
.to-top {
  position: fixed;
  inset-block-end: 1.25rem;
  inset-inline-end: 1.25rem;
  z-index: 40;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  border: 1px solid var(--line-hi);
  border-radius: var(--r-full);
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-3);
  font-size: var(--step-0);
  line-height: 1;
  text-decoration: none;
  transition:
    transform 150ms var(--e-out),
    box-shadow 150ms var(--e-out);
}

.to-top[hidden] {
  /* `display: grid` above would otherwise beat the `hidden` attribute, and the
     button would sit in the corner doing nothing, in the tab order, forever. */
  display: none;
}

.to-top:hover {
  transform: translateY(-2px);
  box-shadow:
    var(--shadow-3),
    0 0 0 1px var(--line-hi);
}

.to-top:active {
  transform: none;
}

/* The status pill is a link now: the version and the state of the emergent
   property are two facts that the HECATE page explains, so the pill points
   there instead of sitting there being a label. */
a.status-pill {
  text-decoration: none;
  transition:
    border-color 150ms var(--e-out),
    color 150ms var(--e-out);
}

a.status-pill:hover {
  border-color: var(--accent, var(--cyan));
  color: var(--text);
}

@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }

  /* The reading bar grows with the scroll position. The browser owns the
     timeline, so the bar costs no JavaScript and cannot drift out of sync
     with the scroll. */
  @supports (animation-timeline: scroll()) {
    .reading-bar__fill {
      transform-origin: left center;
      animation: reading-progress linear both;
      animation-timeline: scroll(root block);
    }
  }

  /* The reveals. The browser knows where the element is in the viewport, so
     nothing is scheduled and nothing is measured: the block fades as it
     crosses. The range starts inside the entry phase, so an element that is
     already on screen when the page loads is never caught invisible. */
  @supports (animation-timeline: view()) {
    .section__body,
    .project,
    .proj-head {
      animation: reveal-up linear both;
      animation-timeline: view();
      animation-range: entry 5% cover 28%;
    }

    /* The stagger. Six cards that fade in as one block read as a single object;
       a short cascade reads as six. `animation-delay` cannot do this: on a scroll
       timeline the progress comes from the element's position, and a time-based
       delay has no clock to delay against. Moving `animation-range` per card is
       the equivalent on this timeline, and it is what the eye actually sees,
       because each card then crosses its own window a little later than the one
       before.

       The offsets are small on purpose: 3% of an entry phase is about 20px of
       scroll, which is enough to see and not enough to make a reader wait for a
       card that is already on screen. */
    .projects > .project:nth-child(2) {
      animation-range: entry 8% cover 30%;
    }

    .projects > .project:nth-child(3) {
      animation-range: entry 11% cover 32%;
    }

    .projects > .project:nth-child(4) {
      animation-range: entry 14% cover 34%;
    }

    .projects > .project:nth-child(5) {
      animation-range: entry 17% cover 36%;
    }

    .projects > .project:nth-child(6) {
      animation-range: entry 20% cover 38%;
    }
  }

  /* The same effect where the view timeline does not exist. The observer that
     adds `is-visible` lives in the inline bootstrap every page already
     carries, so it costs no request and works on the project pages too, which
     load no bundle.

     The rules are behind `.js` on purpose: with scripting off the class is
     never added, and a page whose content is hidden by a stylesheet that no
     script can undo is a blank page. That is not a hypothetical, it is what
     the documentation page shipped in iteration 18. */
  @supports not (animation-timeline: view()) {
    .js.no-view-timeline :is(.section__body, .project, .proj-head) {
      opacity: 0;
      transform: translateY(1.25rem);
      transition:
        opacity 400ms var(--e-out),
        transform 400ms var(--e-out);
    }

    .js.no-view-timeline :is(.section__body, .project, .proj-head).is-visible {
      opacity: 1;
      transform: none;
    }
  }
}

@keyframes reveal-up {
  from {
    opacity: 0;
    transform: translateY(1.25rem);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes reading-progress {
  from {
    transform: scaleX(0);
  }

  to {
    transform: scaleX(1);
  }
}

/* Between pages, the browser fades the old document out and the new one in.
   Same-origin only: the portfolio never leaves hecateforge.com. */
@view-transition {
  navigation: auto;
}

/* One shared element, and only one.
 *
 * The idea is that the flagship's name on the home page and the h1 of its own
 * page are the same object, so the browser morphs one into the other instead of
 * fading the whole document. The name has to be **unique among rendered
 * elements**: two elements carrying the same `view-transition-name` make the
 * browser drop the entire transition, silently, and log to the console. Six
 * cards each claiming `project-title` would therefore switch the effect off
 * everywhere, which is the version of this that looks like it works and does
 * nothing.
 *
 * The second selector is scoped, and it took a measurement to notice why.
 * Written as a bare `.proj-head__title` it matched the h1 of all seven project
 * pages, so navigating from the home page into *any* of them morphed the
 * flagship's card into a heading about a different project. Nothing errored:
 * each document still had exactly one `hecate-heading`, so the transition ran,
 * and the bug was on the cross-document pair rather than inside either page.
 * Reading every page's computed `view-transition-name` is the check that finds
 * it, and it is the check that was missing.
 *
 * So the page carries its own slug in `data-project` and only the flagship's
 * page claims the name. Adding a morph to another project later means adding it
 * here with its own name, not reusing this one. */
#hecate-flagship .project__title,
body[data-project="hecate"] .proj-head__title {
  view-transition-name: hecate-heading;
}

/* The default is a cross-fade plus a size interpolation. The size part is what
   sells it — the heading is 33.6px on a card and 2rem or more on its own page —
   so the timing is set on that group rather than on the whole root. */
::view-transition-group(hecate-heading) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}

/* --- 30. Two micro-interactions ------------------------------------------

   A dot that trails the pointer over the hero, and a few degrees of lean on the
   project cards. Both are opt-in from JavaScript, which appends the dot and
   writes the transform, so with scripting off or with motion reduced there is
   nothing here to see: the rules below only style what the module creates.

   The dot never replaces the pointer. The usual pattern for this effect sets
   `cursor: none` on the region, and then a script that throws leaves the reader
   with no cursor at all over the largest block on the page. */

.micro-cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 30;
  width: 0.55rem;
  height: 0.55rem;
  margin: -0.275rem 0 0 -0.275rem;
  border-radius: var(--r-full);
  background: var(--lime);
  opacity: 0;
  pointer-events: none;
  transition: opacity 180ms var(--e-out);
  /* Its own compositor layer: without this the dot repaints the page it is
     dragged across, which is the whole cost of an effect like this. */
  will-change: transform;
}

.micro-cursor.is-on {
  opacity: 0.9;
}

/* The lean. Only the cards, only with a precise pointer, and only while the
   pointer is on them: `mountMicro` writes the transform and clears it on
   pointerleave, so the resting state is the one in the stylesheet. */
@media (hover: hover) and (pointer: fine) {
  .project {
    /* The transition already names transform; this only says how long the card
       takes to settle back when the pointer leaves. */
    will-change: transform;
  }
}

::view-transition-old(root) {
  animation: vt-out 180ms ease both;
}

::view-transition-new(root) {
  animation: vt-in 260ms ease both;
}

@keyframes vt-out {
  to {
    opacity: 0;
  }
}

@keyframes vt-in {
  from {
    opacity: 0;
  }
}

/* Paper does not scroll. A block that is mid-reveal would print as a blank
   space, so the reveal is dropped for print and the content is put back. */
@media print {
  .section__body,
  .project,
  .proj-head {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }

  .to-top {
    display: none !important;
  }
}

/* --- pressed state, the controls that were missing it ---------------------
   The button had `:active` and every other interactive on the page did not, so a
   finger on the footer call to action, on a theme switch or on a diagram node got
   no answer at all. Each rule below says the same thing in the idiom of its
   control: the surface moves down one pixel and gives up its elevation. Colour is
   the part that survives reduced motion, because colour is not motion. */
.site-footer__cta:active {
  color: var(--lime);
  background-size: 100% 2px;
}

.theme-switch button:active {
  transform: translateY(1px);
}

.project:active {
  transform: translateY(1px);
  box-shadow: var(--shadow-1);
}

/* A text link cannot move down a pixel without taking its line with it, so the
   pressed state is the answer translated into the grammar of a link: the
   underline gets heavier. The pillar links and the two checksum links were the
   only controls on the page that said nothing when pressed. */
a:not(.btn):active {
  text-decoration-thickness: 2px;
}

@media (prefers-reduced-motion: reduce) {
  /* Colour still changes; nothing travels. The theme switch keeps its answer
     another way: pressing it moves `aria-pressed` to the button, which is a
     state change and therefore not motion. */
  .btn:active,
  .theme-switch button:active,
  .project:active {
    transform: none;
  }
}

/* ---------------------------------------------------------------------------
   Project pages.

   One template, six pages. The header of each page is a claim, a mark and the
   four facts a reader asks first (licence, stack, status, where the source is).
   Nothing on these pages is a screenshot: the marks are paths, so the whole
   portfolio still ships zero raster images.
   ------------------------------------------------------------------------ */

.proj-head {
  display: grid;
  gap: var(--space-4, 1.5rem);
  align-items: center;
  padding-block: clamp(2.5rem, 6vw, 5rem) clamp(1rem, 2vw, 1.5rem);
}

@media (min-width: 56rem) {
  .proj-head {
    grid-template-columns: minmax(0, 1fr) auto;
    gap: clamp(2rem, 5vw, 4rem);
  }
}

.proj-head__eyebrow {
  margin: 0;
  font-family: var(--mono);
  font-size: var(--step--2);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.proj-head__title {
  margin: 0.2em 0 0;
  font-size: clamp(2.4rem, 1.4rem + 4.4vw, 4.6rem);
  line-height: 0.96;
  letter-spacing: -0.02em;
  overflow-wrap: anywhere;
}

.proj-head__claim {
  margin: 0.6em 0 0;
  max-width: 46ch;
  font-size: clamp(1.05rem, 0.95rem + 0.5vw, 1.3rem);
  color: var(--text);
}

.proj-head__mark {
  display: block;
  width: clamp(9rem, 22vw, 15rem);
  height: auto;
  color: var(--accent, var(--text));
  opacity: 0.9;
}

.proj-facts {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.6rem;
  margin: 1.2rem 0 0;
  padding: 0;
  list-style: none;
  font-family: var(--mono);
  font-size: var(--step--2);
  color: var(--text-dim);
}

.proj-facts__key {
  color: var(--text);
}

/* The status table. Two columns and no colour that carries meaning alone: the
   word is in the cell, so it survives a monochrome printout and a screen
   reader. */
.org {
  width: 100%;
  margin: 1.4rem 0 0;
  border-collapse: collapse;
  font-size: var(--step--1);
  text-align: left;
}

.org caption {
  padding-block-end: 0.6rem;
  font-family: var(--mono);
  font-size: var(--step--2);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-dim);
  text-align: left;
}

.org th,
.org td {
  padding: 0.55rem 0.9rem 0.55rem 0;
  border-block-end: 1px solid var(--border, rgb(255 255 255 / 0.1));
  vertical-align: top;
}

.org th {
  font-family: var(--mono);
  font-size: var(--step--2);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.org td:first-child {
  font-family: var(--mono);
  white-space: nowrap;
}

.org__yes {
  color: var(--lime);
}

.org__no {
  color: var(--text-dim);
}

/* The status table at phone width.
 *
 * The first column is `nowrap` so the identifiers line up in a column, which is
 * what makes the table scannable at every other width. At 320px that same rule
 * made the table 364px wide with no scroll container around it, so the flagship
 * page scrolled 79px sideways — on the one page a phone visitor is most likely
 * to open. Below 40rem the identifiers wrap and the padding halves instead. */
@media (max-width: 40rem) {
  .org th,
  .org td {
    padding-inline-end: 0.6rem;
  }

  .org td:first-child {
    white-space: normal;
    overflow-wrap: anywhere;
  }
}

/* Previous and next project, at the foot of every project page. */
.projnav {
  margin-block: clamp(2rem, 5vw, 3.5rem);
  padding-block-start: 1.2rem;
  border-block-start: 1px solid var(--border, rgb(255 255 255 / 0.1));
}

.projnav__list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem 1.8rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-family: var(--mono);
  font-size: var(--step--2);
}

/* The topology on the flagship card: twelve cells, one tissue. Drawn, not
   photographed, and small enough that the whole site stays under the image
   budget it has never spent. */
.topo {
  display: block;
  width: 100%;
  max-width: 22rem;
  height: auto;
  margin-inline: auto;
  color: var(--accent, var(--text));
}

/* --- 31. The five illustrations --------------------------------------------

   Until iteration 24 this site had no photograph and no illustration on any
   page: zero <img> against 9, 54 and 133 on the three sites the design was
   measured against. Every mark was drawn SVG, which is why the source looked
   complete and the pages did not.

   The five pieces arrive as raster and are placed two different ways, because
   they do two different jobs. On a project page the image is content: it sits in
   the flow, reserves its box, and is cropped by a fixed ratio so a composition
   cannot push the page around. In the hero it is ground: painted before the
   canvas so the live lattice draws on top of it, and veiled so the claim keeps
   its contrast. */

/* The project figure. The <img> carries width and height attributes so the box
   is reserved before the bytes arrive; the ratio and the max-height here are what
   stop a 4:3 illustration becoming a 900px tall band on a wide viewport. cover
   crops instead of distorting. */
.proj-figure {
  margin: 0 0 clamp(1.5rem, 4vw, 2.5rem);
}

.proj-figure img,
.proj-figure__video {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  max-height: 22rem;
  border: 1px solid var(--line);
  border-radius: var(--r-2);
  object-fit: cover;
  object-position: center;
  /* The illustrations are near black, and the site has a light theme. On paper a
     dark plate needs an edge to read as a deliberate block rather than as a hole
     in the page; the shadow does that without inventing a second border colour. */
  box-shadow: var(--shadow-1);
}

/* The one video on the site, and the fallback that outranks it.
 *
 * The clip and the still are the same plate: one moving, one not. Only one of
 * them is ever displayed. The still is what a reader with reduced motion gets,
 * and it is also what a reader whose browser cannot decode H.264 gets, because
 * `display: none` on the video leaves the <img> in the flow with its own size
 * attributes and the box does not move.
 *
 * `aspect-ratio` here is 16/9, not the 4/3 of the paragraph above: the clip was
 * generated at 1280x720 and stretching it to 4/3 would either crop a third of the
 * frame or distort it. The figure keeps the same height cap either way. */
.proj-figure__video {
  aspect-ratio: 16 / 9;
  background: var(--ink);
}

/* Two classes, not one. `.proj-figure img` above is a class plus an element and
   therefore outranks a bare `.proj-figure__fallback`, so the fallback was
   displayed *underneath* the video — both plates at once, and both looked
   correct in isolation, which is why it took reading the computed styles to see
   it. The scope is what buys the specificity, not `!important`. */
.proj-figure .proj-figure__fallback {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .proj-figure__video {
    display: none;
  }

  .proj-figure .proj-figure__fallback {
    display: block;
  }
}

/* The hero ground. No preload and no background shorthand: it is a real <img>,
   so the preload scanner finds it in the markup, it is decorative so it takes an
   empty alt and aria-hidden, and it sits at z-index 0 directly before the canvas.
   The canvas clears to transparent and strokes over the top, which is the point
   of using a still here rather than a second moving layer. */
.hero__bg {
  position: absolute;
  inset: 0;
  z-index: var(--z-canvas);
  overflow: hidden;
  pointer-events: none;
}

.hero__bg img {
  display: block;
  width: 100%;
  /* Oversize by exactly twice the parallax drift, so translating the plate never
     uncovers an edge inside `.hero__bg` — which is `overflow: hidden` and would
     show a hard seam at the top of the page the moment it did. 4.5rem is 72px,
     and `micro.js` moves this element by at most 34px each way. The two numbers
     are a pair: change one and the seam comes back. */
  height: calc(100% + 4.5rem);
  margin-top: -2.25rem;
  object-fit: cover;
  /* Anchored right so the dense part of the lattice stays in view while the left
     half sits under the claim. */
  object-position: center right;
  /* 0.55 read as a texture behind the type. The plate is the one large piece of
     imagery on the home page, so it is allowed to be seen; the gradient in
     `.hero::before` is what protects the text, not the opacity. */
  opacity: 0.82;
}

/* The claim sits over the image, so the left half is pushed back toward the page
   colour with a gradient rather than a flat scrim: a flat scrim would grey the
   whole hero and cost more contrast than it buys. `--ink` is the page colour in
   whichever theme is on, so the same rule reads correctly on paper and on black
   without a second media query.

The gradient is tuned to the image, not chosen by eye. When the plate was a
   faint texture behind the type it could stop at 70% and nobody would notice;
   with the plate at full strength the scrim has to be opaque further right or
   the sub-heading loses contrast over the bright part of the lattice. 28% and
   62% are where the measured contrast of every hero paragraph stayed above 4.5
   against the plate underneath it. */
.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: linear-gradient(
    100deg,
    var(--ink) 0%,
    color-mix(in srgb, var(--ink) 86%, transparent) 28%,
    transparent 62%
  );
}

/* --- 32. The organism band ------------------------------------------------ */

/* The page's one full-bleed moment after the hero, and the only moving image on
   the home page. It sits between the flagship card and the crate names, so the
   sequence reads: the project, the thing running, its parts, and then the
   argument for why it is built that way.

   Two measured reasons for it to exist at all. The first is rhythm: from
   `#hecate` to the footer every block lives inside the 1264px container, and a
   document whose every horizontal position is decided by one measure reads as a
   column, not as a page. The second is that the site had exactly one piece of
   craft a reader could not generate — the clip — and it was buried a click
   away on a project page.

   Cost was the constraint. `preload="none"` plus the observer in the markup
   means the 883 KB is not requested until the band is 150px from the viewport,
   so the first screen is unchanged by 883 KB, and a reader with reduced motion
   never requests it at all. The poster is the first paint in every case, and it
   is the frame the loop opens on, so the still and the film match.

   The band is a direct child of <main>, which carries no padding, so it is
   full-bleed without a single negative margin. `overflow: clip` rather than
   `hidden`: clip does not create a scroll container, so a horizontal drag
   inside the band cannot turn into a scrollbar on the page. */
.organism {
  position: relative;
  margin: 0;
  border-block: 1px solid var(--line);
  background: var(--ink);
  overflow: clip;
}

.organism__video {
  display: block;
  width: 100%;
  /* A fixed band, not the clip's own 16:9: at 1440 the source ratio would be
     810px tall and would turn one gesture into a screenful. 34vh caps it on a
     laptop and the clamp keeps a floor on a phone. */
  height: clamp(13rem, 34vh, 21rem);
  object-fit: cover;
  /* Anchored slightly above centre: the loop's action is a column rising from
     the bottom, so the crop should take the edges and keep the column. */
  object-position: center 45%;
  /* The one raster allowed to run edge to edge. Held back a tenth so the
     caption and the section rule above it still read as foreground. */
  opacity: 0.9;
}

/* The caption is the band's only text, and the band's lightest frame decides
   whether it is legible. A plate at 88% of the page colour, not a blur: a
   backdrop filter over a decoding video recomposites on every frame, which is
   the cost this page has refused everywhere else. */
.organism__note {
  position: absolute;
  inset-block-end: var(--s-4);
  inset-inline-end: var(--s-5);
  margin: 0;
  /* Anchored to the right edge, so on a phone it has to wrap inside its own
     box rather than grow leftwards until it touches the first column. Measured
     at 320: without the cap the caption ran 281px from the gutter, which put
     its left edge on x=0 where nothing else on the page starts. */
  max-width: calc(100% - 2 * var(--s-5));
  padding: 0.2rem 0.55rem;
  border: 1px solid var(--line);
  border-radius: var(--r-1);
  background: color-mix(in srgb, var(--ink) 88%, transparent);
  font-family: var(--font-mono);
  font-size: var(--step--2);
  letter-spacing: 0.12em;
  text-align: right;
  text-transform: uppercase;
  color: var(--text);
}

/* Printing a video prints its poster at full bleed and empties the cartridge. */
@media print {
  .organism {
    display: none;
  }
}

/* Printing a 60%-black illustration empties the cartridge and tells the reader
   nothing the surrounding text does not. The figure keeps its edge and drops the
   picture. */
@media print {
  .proj-figure img,
  .hero__bg {
    display: none;
  }
}

/* --- 33. The page's one paper act ---------------------------------------- */

/* Why this exists, in the one number that made the decision.

   Measured over the whole raster set, eight of the nine images on this site are
   the same picture: mean luminance 11-33 of 255, between 56% and 94% of the
   frame darker than 25, and a single saturated hue family peaking between 160
   and 220 degrees — thin glowing line work on near-black, eight times. The
   exception is `profile.jpg`: mean 155, saturation 0.115, a photograph.

   So the site had one visual language and one asset outside it, and that asset
   was a 180px circle in the corner of the last section. The register is not a
   pattern any fingerprint catches; it is a default, and a default repeated is
   what makes a page look like every other page built with the same four tools.

   This is the cheapest honest answer available without an illustrator: the act
   that owns the only authentic asset stops sharing the dark field with the other
   four. It does not add an image, a font or a byte of script.

   The mechanism is `color-scheme`, not a second palette. Every token in this
   file is a `light-dark()` pair, and `light-dark()` resolves against the used
   colour scheme of the element it lands on — so declaring the opposite scheme on
   this section re-resolves the entire design system, accents and hairlines and
   shadows included, with no token duplicated and nothing to keep in sync. The
   theme switcher only ever declares a scheme on `:root`; this is the same move,
   one level down. */
.section--invert {
  /* Page resolved dark → this act is paper. The two rules after it flip the
     declaration for the two ways the page can resolve light. */
  color-scheme: light;
  /* `color` and `background-color` have to be declared *here* even though both
     are already set further up the tree, and this is the one thing the mechanism
     does not do by itself: `light-dark()` resolves at the element that uses the
     token, and `color` then inherits as a computed value. Without these two
     lines the section paints a paper ground under text that inherited the dark
     theme's near-white — measured at 1.08:1 on the h2 before the fix. */
  color: var(--text);
  background-color: var(--ink);
  background-image: none;
  border-block: 1px solid var(--line);
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .section--invert {
    color-scheme: dark;
  }
}

:root[data-theme="light"] .section--invert {
  color-scheme: dark;
}

/* The one photograph on the site, printed rather than framed. A circle reads as
   an avatar from a social profile; a square with a hairline reads as a plate in
   a book, which is what this act now is. `grayscale` is not a mood: the portrait
   is already the least saturated thing here at 0.115, and printing it in the
   act's own regime is what stops it from being the only colour in a monochrome
   page. No shadow: paper does not cast one, and `--shadow-2` on a light ground
   is the one shadow shape this file has otherwise refused.

   The folio is left alone on purpose. `.section__number` paints a lime-to-cyan
   gradient clipped to the text, and both stops are `light-dark()` pairs, so on
   paper they resolve to a dark olive and a dark teal without a second rule. That
   numeral is now the only colour in an otherwise monochrome act, which is the
   contract this file's header states: one accent with a fixed meaning, used
   once. */
.section--invert .about__photo {
  aspect-ratio: 4 / 5;
  border-radius: 0;
  border: 1px solid var(--line-hi);
  box-shadow: none;
  filter: grayscale(1) contrast(1.04);
}

