/* ============================================================================
   utomata.net
   Header, sidebar, content, footer. Flat surfaces, hairlines, no shadows, no
   gradients, no motion beyond a colour change. Red is the only accent.

   Sections 6–9 are carried from the pre-revision stylesheet and are formatted
   densely on purpose. The code + syn-* theme in particular is a clone of
   utomata-desktop's editor theme "one": those class names are emitted
   server-side by lib/uto/highlight.js, so renaming one breaks highlighting
   silently. Change colours there only by re-cloning the editor theme.

   Two layout modes, set by data-layout on <body>:
     "docs"  sidebar pages — flush left, whitespace kept clear on the right so
             the viewport panel has somewhere to live
     "page"  everything else — one centred column
   ========================================================================= */

/* ── 1. reset + tokens ───────────────────────────────────────────────────── */

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


* {
  font-variant-ligatures: none;
}

/* A class that sets `display` outranks the UA sheet's `[hidden] { display:none }`
   — same specificity, author origin wins — so anything given a display here
   would go on rendering with the attribute set. That is exactly how the
   viewport's placeholder stayed on top of its iframe and swallowed every
   click. One rule closes it for good. */
[hidden] { display: none !important; }

:root {
  /* surfaces — CRT off-white, not paper */
  --surface: #f0f0ee;
  --surface-2: #e3e3e0;
  --line: #d3d3cf;
  --ink: #161616;
  --ink-mid: #4c4c4c;
  --ink-dim: #8b8b89;
  --red: #c4453c;

  /* Chrome that hosts a running sketch stays dark in both themes — the
     viewport and the hero are windows onto the app, not part of the page. */
  --chrome: #0d0d0d;
  --chrome-2: #1f1f1f;
  --chrome-line: #323232;
  --chrome-ink: #bcb9b9;
  --chrome-ink-bright: #f1f1f1;
  --chrome-ink-dim: #6c6c6c;

  --font-mono: "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
  --font-sans: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
  --font-brand: "Space Grotesk", var(--font-sans);

  --header-h: 56px;
  --sidebar-w: 260px;

  /* --page-w is the CONTENT width of a centred page, and --page-max is the box
     that holds it. Everything centred on this site — the header's inner row,
     every section of a page-layout document, the footer — uses --page-max with
     --page-pad, so all of them end up with a content box exactly --page-w wide
     and their left and right edges land on the same two lines. That is the
     whole alignment story; see the layout policy in section 5.

     1008 is chosen, not inherited: a two-column homepage row at 1fr/2fr with a
     --gap-6 gutter puts the right-hand column on 640px, which is the source
     width of the loops that go in it. Change this and that column follows. */
  --page-w: 1008px;
  --page-pad: 32px;
  --page-max: calc(var(--page-w) + var(--page-pad) * 2);
  /* Sized for the sans body: `ch` is narrower in a proportional face than it
     was in the old monospace one, so this is not the old 78ch minus eight. */
  --measure: 70ch;
  /* The two SIDEBAR layouts, which are not centred and do not use --page-w.
     On a docs page the content column is flush left and the viewport panel is
     pinned right, so the panel clears the text only while
        sidebar + gap + pad + content-max + viewport-w + gap  <=  window
     which at these values comes out at 1436px — a standard laptop. Narrower
     than that and the panel overlays the tail of the line, the way any floating
     tool does. Widen the content by shrinking the panel, or the reverse; they
     are the only two numbers involved.

     --example-max is the same layout with more room, because an example page
     carries a sketch and has no corner panel to leave space for. */
  --content-max: 42rem;
  --example-max: 58rem;
  --viewport-w: 480px;

  --gap-1: 4px;
  --gap-2: 8px;
  --gap-3: 16px;
  --gap-4: 24px;
  --gap-5: 32px;
  --gap-6: 48px;

  --codeFront: var(--red);
  --codeBack: var(--surface-2);

  --uc-red1: #c4453c;
  --uc-green1: #45903b;
  --uc-blue1: #2f7ea3;

  color-scheme: light;
}

/* Dark. Three states: no attribute follows the system, and an explicit
   data-theme on <html> overrides it in either direction. Only the tokens are
   redefined — no rule below this point knows which theme it is in. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --surface: #131313;
    --surface-2: #1c1c1c;
    --line: #2f2f2d;
    --ink: #ececea;
    --ink-mid: #adadaa;
    --ink-dim: #787875;
    --red: #c4453c;
    color-scheme: dark;
  }
}

:root[data-theme="dark"] {
  --surface: #131313;
  --surface-2: #1c1c1c;
  --line: #2f2f2d;
  --ink: #ececea;
  --ink-mid: #adadaa;
  --ink-dim: #787875;
  --red: #c4453c;
  color-scheme: dark;
}

/* ── 2. faces ────────────────────────────────────────────────────────────── */

@font-face {
  font-family: "JetBrains Mono";
  src: url("/assets/fonts/JetBrainsMono-Regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("/assets/fonts/JetBrainsMono-Bold.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("/assets/fonts/JetBrainsMono-Italic.woff2") format("woff2");
  font-weight: 400; font-style: italic; font-display: swap;
}
@font-face {
  font-family: "Space Grotesk";
  src: url("/assets/fonts/SpaceGrotesk-Regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Space Grotesk";
  src: url("/assets/fonts/SpaceGrotesk-Medium.woff2") format("woff2");
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Space Grotesk";
  src: url("/assets/fonts/SpaceGrotesk-Bold.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}

/* ── 3. base ─────────────────────────────────────────────────────────────── */

html { font-size: 16px; }

/* A column the height of the window at minimum, so a short page still puts the
   footer on the bottom edge instead of leaving a band of background under it.
   dvh keeps that honest on mobile, where vh counts browser chrome that is not
   actually there. */
body {
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  color: var(--ink);
  font-family: var(--font-sans);
  line-height: 1.6;
  -webkit-text-size-adjust: 100%;
}

body > .layout { flex: 1 0 auto; }

/* 500, named explicitly. 700 read too heavy. Note this is the only place the
   weight is set globally — .prose h2/h3 carry their own 700 in the prose block
   below, so body headings are unaffected by this line. Regular (400) is loaded
   and unused; it is there to be reached for. */
h1, h2, h3 { font-family: var(--font-brand); font-weight: 500; }
a { color: inherit; }
img, svg { max-width: 100%; }
:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }

/* ── 4. header ───────────────────────────────────────────────────────────── */

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  height: var(--header-h);
  background: var(--surface);
  border-bottom: 1px solid var(--line);
}

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

.site-header__inner {
  height: 100%;
  max-width: var(--page-max);
  margin: 0 auto;
  padding: 0 var(--page-pad);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-4);
}

/* Any page with a sidebar is flush left, so the mark is too — it lines up with
   the sidebar rather than floating in from a centred column. */
[data-layout="docs"] .site-header__inner { max-width: none; margin: 0; }

.brand { display: inline-flex; align-items: center; gap: var(--gap-2); text-decoration: none; }
.brand__mark { display: block; flex: none; }

.brand__word {
  font-family: var(--font-brand);
  font-weight: 500;
  font-size: 1.125rem;
  letter-spacing: 0.01em;
}

.site-nav { 
  display: flex; align-items: center; gap: var(--gap-4); min-width: 0; 
}

.site-nav__link {
  font-family: var(--font-brand);
  font-weight: 500;
  font-size: 0.9375rem;
  color: var(--ink-mid);
  text-decoration: none;
  white-space: nowrap;
  padding: var(--gap-1) 0;
  border-bottom: 2px solid transparent;
}

.site-nav__link:hover { color: var(--ink); }
.site-nav__link.is-current { color: var(--ink); border-bottom-color: var(--red); }
.site-nav__link--pending { color: var(--ink-dim); cursor: default; }

/* ── 5. layout + sidebar ─────────────────────────────────────────────────── */
/* LAYOUT POLICY — three kinds of page, and no fourth without a reason.
 *
 *   1  CENTRED        data-layout="page"
 *      home, blog, CMS pages, errors. One column, --page-w wide, centred in the
 *      window. The header's inner row and the footer use the same box, so the
 *      wordmark sits on the column's left edge and the last nav link on its
 *      right edge. Nothing inside carries its own max-width: the column IS the
 *      limit, and a block that re-states it is a second number to keep in step.
 *
 *   2  DOCS           data-layout="docs"
 *      Flush LEFT — sidebar plus a --content-max column — with the right of the
 *      window deliberately unfilled, because that is where the viewport panel
 *      opens. The header goes full width to match. Not centred, on purpose.
 *
 *   3  EXAMPLE        data-kind="example"
 *      Docs, with --example-max instead: the sketch wants the room and there is
 *      no corner panel to leave space for.
 *
 * The exceptions are the full-bleed ones, and they are enumerated in section 11
 * rather than assumed: the hero fills the window, and everything else in a
 * page-layout column is named in that list to get the column. */

.layout { max-width: var(--page-max); margin: 0 auto; padding: 0 var(--page-pad); }

.layout--with-sidebar {
  display: grid;
  grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
  gap: var(--gap-5);
  align-items: start;
}

/* Sidebar mode: flush left, and the content column stops at a readable width so
   the right of the window stays clear. That whitespace is where the viewport
   panel opens, which is why it is deliberately not filled.
   Example pages are the same layout with a wider cap — the sketch needs the
   room, and they have no corner panel to leave space for. */
[data-layout="docs"] .layout {
  max-width: none;
  margin: 0;
  grid-template-columns: var(--sidebar-w) minmax(0, var(--content-max));
  justify-content: start;
}

/* Only the page that carries a stage needs the extra room. The index is prose,
   so it keeps the same column every other prose page has. */
[data-kind="example"] .layout {
  grid-template-columns: var(--sidebar-w) minmax(0, var(--example-max));
}

.content { min-width: 0; padding: var(--gap-6) 0; }

/* Scrolls itself against a sticky top — no glide, no second document scroller.
   The scrollbar is hidden but the column still scrolls. */
.sidebar {
  position: sticky;
  top: var(--header-h);
  max-height: calc(100vh - var(--header-h));
  overflow-y: auto;
  padding: var(--gap-6) 0;
  scrollbar-width: none;
  border-right: 0.5px solid var(--chrome-line);
}

.sidebar::-webkit-scrollbar { display: none; }
.sidebar__toggle { display: none; }

.book + .book { margin-top: var(--gap-4); }

.book__heading {
  padding: var(--gap-1) 0;
  font-family: var(--font-brand);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-dim);
}

.book__pages { list-style: none; margin-top: var(--gap-1); }

.book__link {
  display: block;
  padding: var(--gap-1) 0 var(--gap-1) var(--gap-3);
  border-left: 2px solid var(--line);
  color: var(--ink-mid);
  text-decoration: none;
  font-size: 0.9375rem;
}

.book__link:hover { color: var(--ink); border-left-color: var(--ink-dim); }

.book__page.is-current > .book__link {
  color: var(--ink);
  font-weight: 700;
  border-left-color: var(--red);
}

/* The current page's h2s, always expanded. h3s are not listed. */
.toc { list-style: none; margin: var(--gap-1) 0 var(--gap-2); }

.toc__link {
  display: block;
  padding: 2px 0 2px var(--gap-4);
  border-left: 2px solid var(--line);
  color: var(--ink-dim);
  text-decoration: none;
  font-size: 0.875rem;
  line-height: 1.45;
}

.toc__link:hover { color: var(--ink-mid); }
.toc__link.is-active { color: var(--ink); border-left-color: var(--red); }

/* ── page head ───────────────────────────────────────────────────────────── */

.page-head { margin-bottom: var(--gap-6); }
.page-head h1 { font-size: 2.125rem; line-height: 1.15; letter-spacing: -0.01em; }
.lede { margin-top: var(--gap-3); color: var(--ink-mid); max-width: 60ch; }

/* ── 6. prose — carried from the previous stylesheet ─────────────────────── */

/* ── prose ─────────────────────────────────────────────────────────────── */

.prose>*+* { margin-top: var(--gap-3); }

.prose h2,
.prose h3,
.prose h4 {
  line-height: 1.5;
  font-weight: 700;
  scroll-margin-top: var(--gap-4);
}

.prose h2 {
  margin-top: var(--gap-6);
  padding-top: var(--gap-3);
  font-size: 1.286rem;
  /* 18px */
}

.prose h3 {
  margin-top: var(--gap-5);
  font-size: 1.071rem;
  /* line-height: 1.5; */
}

/* 15px */
.prose h4 {
  margin-top: var(--gap-4);
  font-size: 1rem;
  color: var(--ink-mid);
}

.prose>h2:first-child {
  margin-top: 0;
  border-top: 0;
  padding-top: 0;
}

/* the red rule is the one piece of decoration: it marks a chapter start */
.prose h2::before {
  content: "";
  display: block;
  width: 24px;
  height: 2px;
  background: var(--red);
  margin-bottom: var(--gap-3);
}
.prose p { max-width: var(--measure); }

.prose a {
  color: var(--ink);
  text-decoration: underline;
  text-decoration-color: var(--red);
  text-underline-offset: 3px;
}
.prose a:hover { color: var(--red); }
.code-example { position: relative; }

/* Only as much room as the button, not a word's worth. */
.code-example>.code { padding-right: 3rem; }

.prose .code-example__action {
  position: absolute;
  top: 2px;
  right: 2px;
  z-index: 1;
  margin: 0;
  max-width: none;
  line-height: 0;
}

/* A quiet square in the block's corner: an icon, no label, no border until it
   is pointed at. It sits over code, so it stays dim and gains contrast on
   hover rather than announcing itself over the thing it belongs to. */
.prose .code-example .play {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 4px;
  color: var(--codeInk, var(--ink-dim));
  background: none;
  text-decoration: none;
  opacity: 0.55;
  transition: opacity 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}

.prose .code-example .play:hover,
.prose .code-example .play:focus-visible {
  opacity: 1;
  color: var(--red);
  border-color: var(--line);
}

.play__icon { display: block; }

.prose strong { font-weight: 700; }

.prose em {
  font-style: italic;
  color: var(--ink-mid);
}

.prose ul,
.prose ol { padding-left: 2.5ch; }
.prose li+li { margin-top: var(--gap-1); }
.prose li::marker { color: var(--ink-dim); }

.prose hr {
  border: 0;
  border-top: 1px solid var(--line);
  margin: var(--gap-5) 0;
}

.prose img {
  display: block;
  max-width: 100%;
  height: auto;
}

/* inline code — tinted, not boxed */
/* CHANGED from `font-family: inherit`: that inherited the body face, which was
   monospace before this revision and is now sans. Pinned to the mono stack so
   inline code still reads as code. */
.prose :not(pre)>code {
  font-family: var(--font-mono);
  font-size: 0.95em;
  font-weight: 100 !important;
  padding: 1px 4px;
  background: var(--codeBack);
  color: var(--codeFront);
  border-radius: 2px;
  /* border: 1px solid var(--line-soft); */
}

/* ── 7. tables — carried ─────────────────────────────────────────────────── */
/* ── tables ────────────────────────────────────────────────────────────── */

/* Ruled both ways: hairlines between cells, a heavier line around the table
   and under the header. */
.prose table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.929rem;
  /* display: block; */
  /* overflow-x: auto; */
  border: 1px solid var(--chrome-ink-dim);
}

.prose th,
.prose td {
  text-align: left;
  vertical-align: top;
  padding: var(--gap-2) var(--gap-3);
  border-right: 1px solid var(--chrome-ink-dim);
  border-bottom: 1px solid var(--chrome-ink-dim);
}

.prose th:last-child,
.prose td:last-child { border-right: 0; }
.prose tbody tr:last-child td { border-bottom: 0; }

.prose th {
  font-weight: 700;
  border-bottom: 2px solid var(--chrome-ink-dim);
  white-space: nowrap;
}

/* Column alignment, which markdown-it wants to write as an inline style and
   lib/markdown.js rewrites into these three classes — see the note there. */
.prose th.align-left, .prose td.align-left { text-align: left; }
.prose th.align-center, .prose td.align-center { text-align: center; }
.prose th.align-right, .prose td.align-right { text-align: right; }

/* ── 8. callouts — carried ───────────────────────────────────────────────── */
/* ── callouts — > NOTE: / > WARNING: / > STATUS: … ─────────────────────── */

.prose blockquote {
  padding: var(--gap-3);
  background: var(--surface-2);
  border-left: 2px solid var(--red);
  color: var(--ink-mid);
}
.prose blockquote h3 { margin-top: var(--gap-2); }
.prose blockquote p+p { margin-top: var(--gap-2); }

.callout__tag {
  font-size: 0.786rem;
  letter-spacing: 0.1em;
  font-weight: 700;
  color: var(--ink);
  margin-right: var(--gap-2);
}

.prose blockquote .callout__tag--heading {
  display: block;
  margin: 0 0 var(--gap-2);
  padding: 0;
  /* font-size: 0.786rem; */
  line-height: 1.2;
}

/* These override .prose blockquote, so they are qualified to match it. */
.prose blockquote.callout--warning {
  padding: var(--gap-4);
  border: 1px solid var(--ink);
  border-left: 4px solid var(--red);
  background: var(--surface-2);
  color: var(--ink);
}
.prose .callout--warning .callout__tag { color: var(--red); }

/* A reusable RGB signal. Warning tags receive this class from the Markdown
   renderer; site-owned HTML can opt in with class="rgb-loop" as well. */
.rgb-loop { animation: rgb-loop 2.4s linear infinite; }

@keyframes rgb-loop {
  0%,
  100% {
    color: #d65d5b;
  }

  33.333% {
    color: #3b914f;
  }

  66.666% {
    color: #3978b8;
  }
}

/* One place for every motion opt-out, so "what moves on this site" is a single
   list. The hero's typer is not here because it is skipped in site.js — there is
   no CSS animation to switch off, the text simply arrives whole. */
@media (prefers-reduced-motion: reduce) {
  .rgb-loop {
    animation: none;
    color: var(--red);
  }

  .hero__lines p:last-child::after { animation: none; }
  .hero__scroll { animation: none; }
  .prose .code-example .play { transition: none; }

  /* The zoom itself stays — it is how you read the image, not decoration. What
     goes is the glide into it. */
  .frame__img { transition: none; }
}
.prose blockquote.callout--note { border-left-color: var(--ink); }

/* STATUS marks an unfinished section. There are dozens of them in the Field
   Guide, so it stays a quiet one-line note rather than a block. */
.prose blockquote.callout--status {
  padding: 0 0 0 var(--gap-2);
  background: none;
  border-left-color: var(--line);
  font-size: 0.929rem;
  color: var(--ink-dim);
}

.prose .callout--status .callout__tag {
  color: var(--ink-dim);
  font-weight: 400;
}

/* ── 9. code blocks ──────────────────────────────────────────────────────── */
/* Lives in its own file: public/css/code-theme.css. Everything that colours
   .uto is there — both palettes, the block, and every .syn-* rule. */

/* ── 10. anchors ─────────────────────────────────────────────────────────── */
/* Native anchor scrolling, cleared of the sticky header. This replaces the
   whole manual scroll-to-hash system the old site.js carried. Declared after
   the prose block so it wins over the scroll-margin carried in there. */

.prose h2, .prose h3, .prose h4, [id] {
  scroll-margin-top: calc(var(--header-h) + 1rem);
}

/* ── 11. site pages ──────────────────────────────────────────────────────── */
/* A site page is a stack of full-bleed sections; each one decides its own
   width. That is what lets the hero fill the window while the prose under it
   stays a readable column at exactly the width it has on every other page. */

[data-layout="page"] .layout { max-width: none; margin: 0; padding: 0; }
[data-layout="page"] .content { padding: 0; }

/* The enumeration is the contract: a direct child of a page-layout content
   column is full-bleed unless it is named here. That is what lets the hero fill
   the window while everything else stays a column. Adding a section type means
   adding it to this list. */
[data-layout="page"] .content > .page-head,
[data-layout="page"] .content > .claims,
[data-layout="page"] .content > .closing,
[data-layout="page"] .content > .stream,
[data-layout="page"] .content > .prose {
  max-width: var(--page-max);
  margin-inline: auto;
  padding-inline: var(--page-pad);
}

[data-layout="page"] .content > .page-head { padding-top: var(--gap-6); }
[data-layout="page"] .hero + .claims { padding-top: var(--gap-6); }
[data-layout="page"] .content > :last-child { padding-bottom: var(--gap-6); }

/* ── 12. hero ────────────────────────────────────────────────────────── */
/* The homepage opens on a running sketch filling the window under the header,
   with the wordmark on a plate over it in the bottom-left corner.

   The plate is opaque and takes --surface, the page's own background, so it
   reads as a piece of the site resting on the sketch. That is what makes it
   legible over a sketch whose colours nobody controls — the alternative, type
   set directly on the frame, is only readable while the sketch stays dark, and
   is a bet that comes due the day someone changes the hero.

   site.js loads the frame when it scrolls into view and blanks it when it
   leaves, so it is not holding the GPU behind the rest of the page. */

.hero {
  position: relative;
  height: calc(100vh - var(--header-h));
  height: calc(100dvh - var(--header-h));
  min-height: 420px;
  overflow: hidden;
  background: var(--chrome);
}

/* A static header does not eat any height, so the hero gets the whole window. */
.site-header--static + .layout .hero { height: 100vh; height: 100dvh; }

/* Ambient, not interactive. Without this the iframe takes the wheel and the
   drag, and the homepage will not scroll while the cursor is over it. */
.hero__stage { position: absolute; inset: 0; pointer-events: none; }

.hero__frame {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  opacity: 0;
  transition: opacity 0.5s ease;
}

.hero__frame.is-ready { opacity: 1; }

/* A still, for a browser with no WebGPU. Both live in the stage; the class on
   <html> decides which one is in the layout, so there is never a moment where
   both or neither is showing. `cover` because the still's ratio is fixed and
   the stage's is the window's. */
.hero__fallback {
  display: none;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ── no WebGPU ───────────────────────────────────────────────────────────────
   Every sketch on this site is an iframe onto uto.run, and uto.run needs
   WebGPU. A browser without it gets NOTHING that would sit there as a black
   rectangle waiting for a thing that is never going to start.

   `no-webgpu` is set on <html> twice over: synchronously by the bootstrap in
   partials/head.ejs when `navigator.gpu` is missing, which is before first
   paint and covers the whole class of browsers that cannot run a sketch at
   all; and again from site.js when `requestAdapter()` yields nothing, which is
   async and catches a browser that has the API without a usable GPU behind it.
   The second one can only land after paint, so these rules have to be able to
   take a sketch away as well as never show one — which is why each of them is
   a swap between two things already in the markup, not a choice made once.

   site.js blanks the frames themselves; this only decides what is in the
   layout. Both are needed: CSS alone would hide an iframe that still loaded
   780KB of engine to run nothing. */

.no-webgpu .hero__fallback { display: block; }
.no-webgpu .hero__frame { display: none; }

/* The example page keeps its program and loses its stage. The message is in
   the markup on every page and hidden by default, so there is no request and
   no template branch — the class decides which of the two is showing. */
.no-webgpu .stage__frame { display: none; }
.no-webgpu .stage__unsupported { display: grid; }

/* The homepage's closing widget goes entirely, note and all. It is one box
   whose whole argument is "here is the program, here it is running", and the
   note under it does the narrating — half of that, with a narrator still
   describing the missing half, is worse than neither. */
.no-webgpu .closing { display: none; }

/* No play control in a code block, so the viewport is never opened. site.js
   also refuses to open it, because this class can arrive after a reader has
   already got their pointer moving. A plain uto.run link in the PROSE is left
   alone and navigates normally: it is a sentence, removing it would leave a
   hole in the text, and uto.run says the same thing about the browser far
   better than a hidden link could. */
.no-webgpu .play { display: none; }

/* The plate is a column of the page, not a corner ornament: the type inside it
   lines up with the claims' type below, and it narrows with them as the window
   does. Three derived numbers do that, all resolved against the hero's own
   width so none of them counts a scrollbar the way 100vw would:

     --col-left  where the centred page column starts. `max()` gives the centred
                 edge on a wide window, and the plain gutter once the window is
                 narrower than the column.
     --col-w     that column's width.
     --text-w    a claim's text column — 1fr of a 1fr/2fr row, so a third of the
                 column minus its share of the gutter. 320px at full width.

   The box is then pulled LEFT by its own padding, which is the whole point: the
   plate has to start before its text for the text to land on the same line as
   the headings underneath. */
.hero__plate {
  --col-left: max(var(--page-pad), calc((100% - var(--page-max)) / 2 + var(--page-pad)));
  --col-w: min(var(--page-w), calc(100% - var(--page-pad) * 2));
  --text-w: calc((var(--col-w) - var(--gap-6)) / 3);

  position: absolute;
  left: calc(var(--col-left) - var(--gap-5));
  bottom: var(--hero-inset, 32px);
  width: calc(var(--text-w) + var(--gap-5) * 2);
  padding: var(--gap-5);
  background: var(--surface);
  color: var(--ink);
  border-radius: 4px;
}

.hero__title {
  font-size: clamp(1.75rem, 4vw, 2.5rem);
  line-height: 1.05;
  letter-spacing: -0.02em;
}

/* The plate grows UP. `bottom` is the anchored edge and there is no `top`, so
   every line the typer adds pushes the plate's top edge further up the sketch
   while its corner stays put.

   The width is fixed rather than fitted, because a plate sized to its content
   would step wider on almost every word typed into it. `34ch` is about eight
   words of this face — the measure the longest line already had. */
.hero__lines {
  margin-top: var(--gap-2);
  color: var(--ink-mid);
}

.hero__lines p + p { margin-top: var(--gap-2); }

/* The caret is on the last line only, so it reads as one cursor moving down the
   plate rather than one per line. It stays after the typer finishes: this is a
   prompt, and a prompt without a cursor looks switched off. */
.hero__lines p:last-child::after {
  content: "";
  display: inline-block;
  width: 0.5em;
  height: 1em;
  margin-left: 2px;
  background: var(--red);
  vertical-align: -0.15em;
  animation: hero-caret 1.1s steps(1) infinite;
}

@keyframes hero-caret {
  0%, 50% { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}

/* Centred on the window, not on the plate, and low enough to read as an edge
   cue rather than as part of the wordmark. Red because it is the site's only
   accent and this is the only thing on the first screen asking to be used. */
.hero__scroll {
  position: absolute;
  left: 50%;
  bottom: var(--hero-inset, 16px);
  z-index: 1;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin-left: -22px;
  color: var(--red);
  opacity: 0.75;
  transition: opacity 0.2s ease, transform 0.2s ease;
  animation: hero-nudge 2.4s ease-in-out infinite;
}

.hero__scroll:hover,
.hero__scroll:focus-visible { opacity: 1; }

@keyframes hero-nudge {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(4px); }
}

/* ── 12a. homepage claims ────────────────────────────────────────────── */
/* One claim per row: the words on the left, a loop of the thing on the right.
   The text column is the smaller of the two on purpose — these are one-line
   claims, and giving them half the width would set four words across a measure
   built for paragraphs. `minmax` puts a floor under it so it never collapses to
   a word per line while the media keeps growing.

   The media slot is sized entirely by this rule, so the placeholder div can be
   swapped for a <video class="claim__media"> and nothing moves. */

/* No max-width: the row fills the page column, which is --page-w wide for
   exactly this reason — 1fr/2fr across 1008 with a --gap-6 gutter lands the
   media on 640, the source size of the loops. Capping it here again would be a
   second number saying the same thing, and the two would drift. */
.claim {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
  gap: var(--gap-6);
  /* Top, not centre. Centring hangs a two-line claim in the middle of a 480px
     frame with nothing above or below it, which reads as a mistake rather than
     as composition. Aligned to the top, the heading and the first frame of the
     loop start on the same line. */
  align-items: start;
  padding-block: var(--gap-6);
}

/* The same rule .prose h2 carries — these are chapter starts too, and with it
   the rows need no dividing lines of their own. */
.claim__text h2::before {
  content: "";
  display: block;
  width: 24px;
  height: 2px;
  background: var(--red);
  margin-bottom: var(--gap-3);
}

/* Sticky within its own row, and no further. A grid item's containing block is
   its grid area, so the text travels down as the loop scrolls past and is
   released the moment the row's bottom edge reaches it — it cannot wander into
   the next claim. `align-items: start` on the row is what leaves it the room to
   travel: stretched, it would already fill the area and have nowhere to go.

   Two columns only. Below 700px the row is one column and the text sits under
   its loop, where sticking it would pin a paragraph over the next section. */
.claim__text {
  position: sticky;
  top: calc(var(--header-h) + var(--gap-4));
}

.claim__text h2 {
  font-size: 1.5rem;
  line-height: 1.25;
  letter-spacing: -0.01em;
}

.claim__text p {
  margin-top: var(--gap-3);
  color: var(--ink-mid);
}

/* Fills its column and holds the ratio; the column's width is what makes it
   640 wide on a full-size window, so the loops are mastered at 1280×720 — 2× on
   a retina display.

   16:9 rather than 4:3: it is the shape screens are recorded in, it costs a
   quarter fewer pixels for the same width, and stacked on a phone it takes far
   less of the window before the text under it — a column of 4:3 frames on a
   narrow screen is most of the scroll. */
.claim__media {
  aspect-ratio: 16 / 9;
  width: 100%;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--surface-2);
  object-fit: cover;
  overflow: hidden;
}

/* ── 12a-i. media frames ─────────────────────────────────────────────────── */
/* A still in the same box a loop uses, so a section can change from one to the
   other and nothing around it moves.
 
   `contain`, not `cover`. A loop is shot to fill the frame; a still is a thing
   you are meant to read — a cheatsheet, a detail of the particle system — and
   cropping it to fill defeats the reason for using one. A narrower image sits
   centred with the frame's own background either side.
 
   HOVER ZOOM. The frame is the window and the image scales inside it, with the
   transform origin following the pointer: the corner you point at is the corner
   you get, which is the behaviour every product catalogue has trained people to
   expect. Only two custom properties cross from JS — the origin — so the scale
   and its easing stay here in CSS, and there is no transform arithmetic in a
   script. */

.frame--image {
  /* The image is positioned against this box, not laid out in it. A grid row
     sized by its own content cannot resolve a percentage height on that
     content — the two define each other — which is what made `contain` and
     `height` silently fall back to filling the width. The frame's height is
     definite (its aspect-ratio gives it one), so an absolutely positioned child
     resolves 100% against something real. */
  position: relative;
  display: block;
  overflow: hidden;
  background: var(--surface-2);
}

.frame__img {
  display: block;
  /* `inset: 0` with `margin: auto` centres on whichever axis the image does not
     fill, without touching `transform` — which belongs to the zoom. */
  position: absolute;
  inset: 0;
  margin: auto;
  transform-origin: var(--zoom-x, 50%) var(--zoom-y, 50%);
  transition: transform 0.25s ease;

  /* Not a hit target. The image grows and moves under the pointer while it is
     being tracked, and every one of those geometry changes is another
     hit-test — which the browser answers by re-resolving the cursor, and which
     shows up as the cursor flickering between the frame's and the image's.
     With the image transparent to pointer events the frame is the only target
     and the cursor is resolved once. */
  pointer-events: none;
}

/* Three ways to sit in the frame, chosen per section by data-fit.

   contain  the whole image, letterboxed on whichever axis is short. Never
            crops. The right default, and the right choice for anything you are
            meant to READ.
   width    fill the width. Taller-than-the-frame overflow is cut off top and
            bottom.
   height   fill the height. Wider-than-the-frame overflow is cut left and right.

   Worth knowing, because it looks like a bug otherwise: `contain` always equals
   whichever of the other two is SMALLER, so it is never distinct from both. And
   when the image's ratio matches the frame's, all three are identical and the
   setting appears to do nothing — which is not a fault, there is simply nothing
   for it to decide. It only has an effect on an image shaped differently from
   its frame. */
.frame--image[data-fit="contain"] .frame__img {
  max-width: 100%;
  max-height: 100%;
}

.frame--image[data-fit="width"] .frame__img {
  width: 100%;
  height: auto;
  max-width: none;
}

.frame--image[data-fit="height"] .frame__img {
  height: 100%;
  width: auto;
  max-height: none;
}

/* Only a frame that opted in gets the cursor and the scale. --zoom-scale is set
   by site.js from the data attribute, because an inline style attribute is what
   the Content-Security-Policy blocks. */
.frame--image[data-zoom] { cursor: zoom-in; }

.frame--image[data-zoom]:hover .frame__img,
.frame--image[data-zoom]:focus-visible .frame__img {
  transform: scale(var(--zoom-scale, 2));
}

/* ── 12a-ii. a frame that is still waiting ───────────────────────────────── */
/* The frames are laid out before their contents arrive — that is the point of
   sizing them in CSS, and it is why the page never reflows when a loop loads.
   The cost is that on a slow connection the first seconds of the homepage are
   a column of empty rectangles, which reads as broken rather than as loading.

   So a frame that is still waiting gets a sheen: a very soft diagonal band
   moving across the tint it already has. Deliberately faint — 6% of the ink
   colour — because it is decoration over the page's real content and the moment
   it competes for attention it has become worse than the empty box it replaced.

   Added by site.js and removed on load, never rendered by the server. With
   JavaScript off the images still arrive by themselves, and a shimmer that
   nothing would ever switch off is worse than no shimmer at all. An image
   already in cache never gets the class, so a warm reload does not flash. */
@keyframes media-pending {
  from { background-position: 180% 0; }
  to   { background-position: -80% 0; }
}

.is-pending {
  background-color: var(--surface-2);
  background-image: linear-gradient(
    100deg,
    transparent 25%,
    color-mix(in srgb, var(--ink) 6%, transparent) 45%,
    transparent 65%
  );
  background-repeat: no-repeat;
  background-size: 220% 100%;
  animation: media-pending 1.9s ease-in-out infinite;
}

/* On a still page this sheen is the only thing moving, which makes it the
   first thing to go. The flat tint underneath still says "a frame is here",
   which was the whole job. A browser without `color-mix` lands here too, by
   dropping the gradient it cannot resolve. */
@media (prefers-reduced-motion: reduce) {
  .is-pending { background-image: none; animation: none; }
}

/* ── 12a-iii. the homepage's closing widget ──────────────────────────────── */
/* One box: the program on the left, the same program running on the right, and
   nothing else — no heading, no caption. The note below it narrates.

   The two panes meet with no gap and one hairline. Rounding is on the box, not
   on the panes, so the corners belong to the widget and the seam stays a
   straight line — `overflow: hidden` is what clips the code's own background
   and the iframe to those corners.

   Like everything else in this column it carries no width of its own: it fills
   --page-w, the canvas takes two thirds of that and the code the remaining
   third. The note beneath fills the same column, so its rule starts and ends on
   the widget's own edges. */

.closing { padding-block: var(--gap-6); }

.closing__widget {
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.89fr);
  border: 1px solid var(--line);
  border-radius: 4px;
  overflow: hidden;
}

/* The pane is stretched to the row's height by the grid, and the row's height
   is the canvas's — so `min-height: 0` is what lets a long program scroll
   inside it instead of making the box taller than the sketch.

   The scrollbar is hidden on purpose: it would sit right beside the hairline
   and read as a second, wobblier divider. The pane still scrolls by wheel,
   trackpad, touch and keyboard. */
/* Selected through its parent, not on its own. code-theme.css is loaded AFTER
   this file and sets `.code { border; border-radius }` — the same specificity,
   so a bare `.closing__code` loses and the pane keeps its own rounded box
   inside the widget's. One extra class settles it here, rather than reaching
   into the theme file, which owns the block everywhere else. */
.closing__widget > .closing__code {
  min-height: 0;
  overflow: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
  margin: 0;
  border: 0;
  border-right: 1px solid var(--line);
  border-radius: 0;
  /* Tighter side padding than a normal block, and measured rather than chosen:
     at gap-3 the widest line in a short example overran the pane by 9px, and
     with the scrollbar hidden that clips silently. The narrower gutter buys
     back more than the overrun and leaves the program fitting the column, which
     is the point of showing it at all. */
  padding: var(--gap-3) var(--gap-2);
  font-size: 0.75rem;
  line-height: 1.5;
}

.closing__widget > .closing__code::-webkit-scrollbar { width: 0; height: 0; }

/* Dark while it loads, because what arrives is a sketch and a light rectangle
   that turns black reads as a flash. */
.closing__canvas {
  display: block;
  width: 100%;
  height: 100%;
  min-height: 320px;
  /* aspect-ratio: 4 / 3; */
  border: 0;
  background: var(--chrome);
}

/* The rule above it spans the column, which is the widget's width too — so the
   two line up without either of them being told a number. */
.closing__note {
  margin-top: var(--gap-6);
  /* padding-top: var(--gap-6); */
  /* border-top: 1px solid var(--line); */
}

/* ── 12b. examples ───────────────────────────────────────────────────────── */
/* An example page is the sidebar layout with a stage on top. The stage sits in
   the content column like everything else — same left edge as the code below
   it, same right margin — so the page reads as one column, not as a banner
   with a document under it. */

.stage {
  position: relative;
  aspect-ratio: 16 / 9;
  max-height: calc(100vh - var(--header-h) - var(--gap-6));
  margin-bottom: var(--gap-5);
  background: var(--chrome);
  border: 1px solid var(--chrome-line);
  border-radius: 4px;
  overflow: hidden;
}

.stage__frame { display: block; width: 100%; height: 100%; border: 0; }

/* Removed the day the sketches are published — until then a bare black
   rectangle reads as broken rather than as pending. */
.stage__pending {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--chrome-ink-dim);
  font-size: 0.875rem;
}

/* Same box, same treatment — the stage is dark chrome in both themes, so this
   reads against it the way the pending message does. `display: none` until the
   no-webgpu rule up in section 9 turns it into a grid. */
.stage__unsupported {
  display: none;
  position: absolute;
  inset: 0;
  place-items: center;
  align-content: center;
  gap: var(--gap-1, 4px);
  padding: var(--gap-4);
  margin: 0;
  text-align: center;
  text-wrap: balance;
  color: var(--chrome-ink-dim);
  font-size: 0.875rem;
  line-height: 1.5;
}

.example-meta {
  margin-top: var(--gap-3);
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--gap-2);
  color: var(--ink-dim);
  font-size: 0.875rem;
}

.example-meta a { color: var(--ink-mid); }

.example-tag {
  padding: 1px var(--gap-2);
  border: 1px solid var(--line);
  border-radius: 999px;
  font-size: 0.75rem;
  letter-spacing: 0.04em;
  color: var(--ink-dim);
}

/* ── 12c. posts ──────────────────────────────────────────────────────── */
/* The blog is one stream of whole posts, newest first — no index, no page per
   post, no title over it. A post's body is .prose markup from the renderer the
   docs go through, so nothing here styles body copy; what is here is the seam
   between one post and the next, the line of metadata above each, and the two
   controls that pick which posts you are looking at.

   Search and paging are both plain GET links to this same URL, so a result set
   and page three are ordinary addresses: linkable, cacheable, and correct under
   the back button without a line of JavaScript. */

/* The stream opens on a post, not on a heading or a rule. Its top padding is
   what keeps that first post clear of the pinned search box above it. */
.stream { padding-top: calc(var(--header-h) + var(--gap-3)); }

/* Pinned under the header, and to the SAME right edge as the last nav link —
   the `max()` resolves to the page column's right edge on a wide window and to
   the plain gutter once the window is narrower than the column, which is what
   keeps it aligned at every width instead of only at one.

   It sits in the empty right-hand margin the stream leaves: .prose p is capped
   at --measure, well short of the column's edge, so on a wide window the box
   floats over nothing. Below 920px that margin is gone and the box rejoins the
   flow instead (see section 16).

   z-index under the header's 50: it scrolls beneath the header, not over it. */
.search {
  position: fixed;
  top: calc(var(--header-h) + var(--gap-3));
  right: max(var(--page-pad), calc((100% - var(--page-max)) / 2 + var(--page-pad)));
  z-index: 40;
  display: flex;
  gap: var(--gap-2);
}

.search__input {
  width: 12.5rem;
  max-width: 100%;
  padding: var(--gap-2) var(--gap-3);
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 0;
  font: inherit;
  font-size: 0.9375rem;
  /* Safari draws a search input with rounded corners and an inner shadow that
     no border rule reaches. This is the only way to get the site's flat box. */
  -webkit-appearance: none;
  appearance: none;
}

.search__input:focus-visible { outline: 2px solid var(--red); outline-offset: -1px; }
.search__input::placeholder { color: var(--ink-dim); }

.search__submit {
  padding: var(--gap-2) var(--gap-3);
  background: var(--surface);
  color: var(--ink-mid);
  border: 1px solid var(--line);
  font: inherit;
  font-size: 0.9375rem;
  cursor: pointer;
}

.search__submit:hover { color: var(--red); border-color: var(--red); }

.stream__result {
  margin-bottom: var(--gap-5);
  color: var(--ink-mid);
  font-size: 0.9375rem;
}

.stream__result a { margin-left: var(--gap-2); }

/* A rule BETWEEN posts, and nowhere else. On `.post` alone it also drew above
   the first one, which put a stray line across the top of the page under the
   header and read as a broken header rather than as a separator. */
.post + .post {
  padding-top: var(--gap-6);
  margin-top: var(--gap-6);
  border-top: 1px solid var(--line);
}

.post__head { margin-bottom: var(--gap-4); }

.post__title {
  margin-top: var(--gap-2);
  font-size: 1.75rem;
  line-height: 1.2;
  letter-spacing: -0.01em;
}

/* The title links to the post's own anchor: with no page per post, that is
   what a permalink into the stream is. Undecorated until hovered — a whole
   column of underlined headings reads as a list of links, not as writing. */
.post__title a { text-decoration: none; }
.post__title a:hover { color: var(--red); }

/* Machine metadata, set like the rest of the site's: mono, small, tracked, dim.
   Shares the .example-tag chip with an example page rather than growing a
   second one. */
.post-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--gap-2);
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  color: var(--ink-dim);
}

.post-meta__sep { color: var(--line); }

.pager {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gap-3);
  margin-top: var(--gap-6);
  padding-top: var(--gap-4);
  border-top: 1px solid var(--line);
}

.pager__link { text-decoration: none; }
.pager__link:hover { color: var(--red); }

/* An edge of the stream is shown, not hidden: a disabled control in place tells
   you where you are, and a missing one just moves the other control. */
.pager__link--off { color: var(--ink-dim); }

.pager__count {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  color: var(--ink-dim);
}

/* A post's body carries CMS media at whatever size it was uploaded, which is
   the first time prose images have not been hand-sized by whoever wrote the
   page. Corrective only: cap to the column, and keep the ratio — without the
   height rule an image carrying width/height attributes stretches once the
   width is capped. No border and no margin, because this rule is shared with
   every docs image and framing those is a separate decision. */
.prose img {
  display: block;
  max-width: 100%;
  height: auto;
}

/* Present to the document and to a screen reader, absent from the page. */
.u-visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ── 12d. spec sheet ─────────────────────────────────────────────────────── */
/* One page, one image, two links. The sheet is a 1382×1956 PNG of the same A4
   spread the PDF holds — shown rather than framed, because a <embed>ed PDF is a
   scroll region inside a scroll region on a desktop and unreadable on a phone,
   while an image is just an image and the PDF is one click away.

   width/height are on the <img> so the row it occupies is reserved before the
   500KB decodes; without them the whole page reflows when it lands. */

.specsheet__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gap-2);
  margin-top: var(--gap-4);
}

.specsheet__action {
  padding: var(--gap-2) var(--gap-3);
  border: 1px solid var(--red);
  color: var(--red);
  font-family: var(--font-brand);
  font-size: 0.9375rem;
  font-weight: 500;
  text-decoration: none;
}

.specsheet__action:hover { background: var(--red); color: var(--surface); }

/* Outside .prose, so it takes the full content column rather than the reading
   measure — the sheet is only legible at width. */
.specsheet__sheet {
  display: block;
  margin-top: var(--gap-5);
  border: 1px solid var(--line);
}

.specsheet__sheet img { display: block; width: 100%; height: auto; }

/* ── 13. viewport overlay ────────────────────────────────────────────────── */
/* Opened by a "Run >" control in a code example, or by any plain uto.run link
   in the prose. Top right, under the header, in the whitespace the docs layout
   deliberately leaves clear. Resizes from its bottom-left corner. */

.viewport {
  position: fixed;
  top: calc(var(--header-h) + var(--gap-3));
  right: var(--gap-3);
  z-index: 100;
  display: flex;
  flex-direction: column;
  width: min(var(--viewport-w), calc(100vw - 2rem));
  height: min(380px, calc(100vh - var(--header-h) - 2rem));
  overflow: hidden;
  background: var(--chrome);
  border: 1px solid var(--chrome-line);
  border-radius: 4px;
}

/* Beats the display:flex above — without this the panel never closes. */
.viewport[hidden] { display: none; }

.viewport__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-3);
  padding: var(--gap-2) var(--gap-3);
  background: var(--chrome-2);
  border-bottom: 1px solid var(--chrome-line);
  color: var(--chrome-ink);
  font-size: 0.8125rem;
}

.viewport__title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.viewport__actions { display: flex; align-items: center; gap: var(--gap-3); }

.viewport__external, .viewport__close {
  border: 0;
  background: none;
  padding: 0;
  color: var(--chrome-ink);
  cursor: pointer;
  font: inherit;
  line-height: 1;
  text-decoration: none;
}

.viewport__external:hover, .viewport__close:hover { color: var(--chrome-ink-bright); }
.viewport__body { position: relative; flex: 1; min-height: 0; }
.viewport__frame { display: block; width: 100%; height: 100%; border: 0; }

/* Bottom-left, because the panel is pinned top-right: dragging out and down
   grows it away from its anchor. */
.viewport__resize {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 18px;
  height: 18px;
  border: 0;
  padding: 0;
  background: none;
  cursor: nesw-resize;
  touch-action: none;
}

.viewport__resize::before {
  content: "";
  position: absolute;
  left: 4px;
  bottom: 4px;
  width: 7px;
  height: 7px;
  border-left: 1px solid var(--chrome-ink-dim);
  border-bottom: 1px solid var(--chrome-ink-dim);
}

/* ── 14. prev / next ─────────────────────────────────────────────────────── */

.prevnext {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--gap-4);
  margin-top: var(--gap-6);
  padding-top: var(--gap-4);
  border-top: 1px solid var(--line);
}

.prevnext__link {
  display: flex;
  flex-direction: column;
  gap: var(--gap-1);
  max-width: 22rem;
  text-decoration: none;
}

.prevnext__link--next { margin-left: auto; text-align: right; }
.prevnext__dir { font-size: 0.8125rem; color: var(--ink-dim); }

.prevnext__title {
  font-family: var(--font-brand);
  font-weight: 500;
  color: var(--ink-mid);
}

.prevnext__link:hover .prevnext__title { color: var(--red); }

/* Only useful once the sidebar has become a disclosure at the top. */
.prevnext__index { display: none; }

/* ── 15. footer ──────────────────────────────────────────────────────────── */
/* Three unlabelled groups: the header's routes, every other page the site
   serves, and the links that leave it. The middle one is generated, so a page
   published in Strapi appears here without anyone editing a template.

   `auto-fit` with `1fr` rather than `max-content`: the groups are short lists of
   short words, and left to their own widths they cluster against the left of
   their area and leave a wide gap at the right of a box whose edges now line up
   with everything else on the page. Equal columns fill it, and still wrap as a
   set when the row runs out. */

.site-footer {
  margin-top: var(--gap-6);
  background: var(--surface-2);
  border-top: 1px solid var(--line);
}

.site-footer__inner {
  display: grid;
  grid-template-columns: minmax(200px, 1fr) 2fr;
  gap: var(--gap-6);
  max-width: var(--page-max);
  margin: 0 auto;
  padding: calc(var(--gap-6)*2) var(--page-pad);
}

.site-footer__blurb p {
  margin-top: var(--gap-3);
  max-width: 32ch;
  color: var(--ink-mid);
  font-size: 0.9375rem;
}

/* Three groups, no headings. The padding is what the headings used to
   contribute: without it the first link sits on the wordmark's own line
   opposite and the two read as one row rather than as two halves of a footer. */
.site-footer__columns {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: var(--gap-5) var(--gap-6);
  align-content: start;
  /* padding-top: var(--gap-4); */
}

.site-footer__column { list-style: none; }
.site-footer__column ul { list-style: none; }
.site-footer__column li + li { margin-top: var(--gap-1); }

/* Last, so the small print reads as the end of the footer rather than as one
   more group of destinations. */
.site-footer__column--admin { max-width: 20ch; }

/* The last item of the admin list rather than a heading over it. Dimmed and
   lightened instead of resized, so it sits in the list without reading as a
   fifth thing to click. */
.site-footer__policy {
  color: var(--ink-dim);
  font-weight: 200;
}


/* Small, dim and never on its own line — it marks the link without becoming a
   second word in it. */
.site-footer__out {
  margin-left: var(--gap-1);
  font-size: 0.75em;
  color: var(--ink-dim);
}

.site-footer__column a {
  color: var(--ink-mid);
  text-decoration: none;
  font-size: 0.9375rem;
}

.site-footer__column a:hover { color: var(--red); }

/* Where you are, in the same vocabulary the header uses: full-strength ink and
   the red mark. Underlined rather than bordered, because these are inline links
   in a list and a bottom border would sit under the whole row. */
.site-footer__column a.is-current {
  color: var(--ink);
  text-decoration: underline;
  text-decoration-color: var(--red);
  text-underline-offset: 4px;
}

.theme-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--gap-2);
  margin-top: var(--gap-4);
  padding: var(--gap-1) var(--gap-3) var(--gap-1) var(--gap-2);
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface);
  color: var(--ink-mid);
  cursor: pointer;
  font: inherit;
  font-size: 0.8125rem;
}

.theme-toggle:hover { color: var(--ink); border-color: var(--ink-dim); }
.theme-toggle__icon { width: 14px; height: 14px; display: block; }

/* ── 16. responsive ──────────────────────────────────────────────────────── */
/* Three widths, and every rule below belongs to one of them. Adding a fourth
   should need an argument, because a breakpoint nothing else shares is a place
   where one page starts behaving unlike the rest of the site.

     920   the site runs out of room BESIDE its column — the sidebar narrows,
           and the pinned blog search drops back into the flow
     700   two columns become one — the sidebar becomes a disclosure, the
           homepage claims stack, the viewport panel becomes a bottom sheet
     560   phone furniture — the wordmark leaves the header, type steps down */
@media (max-width: 920px) {
  :root { --sidebar-w: 190px; }

  /* The pinned search box lives in the empty margin the stream leaves to the
     right of its text. By here that margin is narrower than the box, so it
     would sit on the writing instead — back into the flow it goes. Same tier as
     the sidebar narrowing, for the same reason: this is the width at which the
     site stops having room to the side of its column. */
  .search {
    position: static;
    width: max-content;
    max-width: 100%;
    margin: 0 0 var(--gap-5) auto;
  }

  .stream { padding-top: var(--gap-5); }
}

@media (max-width: 700px) {
  :root { --page-pad: 20px; }

  /* One column, and the loop goes above the words it is evidence for. Reordered
     here rather than in the markup so the reading order stays heading-then-text
     for anything that does not lay the grid out.

     700, with the sidebar, and not a breakpoint of its own: this is where the
     site as a whole stops being two columns, and a homepage that had already
     given up on side-by-side at 920 would be answering a different question
     from every other page. */
  .claim {
    grid-template-columns: 1fr;
    gap: var(--gap-4);
    padding-block: var(--gap-5);
  }

  .claim__media { order: -1; }
  .claim__text { position: static; }

  /* The claims are one column here, so "a third of the column" no longer means
     anything: the plate keeps the width it has at full size and returns to the
     corner inset. max-width is the guard for a phone narrower than that. */
  .hero__plate {
    --text-w: 320px;
    left: var(--hero-inset, 16px);
    max-width: calc(100% - var(--hero-inset, 16px) * 2);
  }

  /* The same widget, stacked. The canvas goes first for the same reason a
     claim's loop does, and the code below it drops its scroller: at one column
     the pane is sized by its own content, so every line of the program is
     there without a scroll region inside a scrolling page. The hairline moves
     from the pane's side to its top, where the seam now is. */
  .closing__widget { grid-template-columns: 1fr; }
  .closing__canvas { order: -1; }

  .closing__widget > .closing__code {
    height: auto;
    border-right: 0;
    border-top: 1px solid var(--line);
  }

  /* The sidebar stops being a column and becomes a disclosure. It stays stuck
     under the header so the index is always one tap away — that, plus the
     "↑ Contents" link at the foot of the page, is how you get back to it. */
  .layout--with-sidebar,
  [data-layout="docs"] .layout { display: block; max-width: none; }

  .sidebar {
    position: sticky;
    top: var(--header-h);
    z-index: 40;
    max-height: none;
    overflow: visible;
    padding: 0;
    background: var(--surface);
    border-bottom: 1px solid var(--line);
  }

  .sidebar__toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    padding: var(--gap-3) 0;
    border: 0;
    background: none;
    cursor: pointer;
    font-family: var(--font-brand);
    font-size: 0.9375rem;
    font-weight: 500;
    color: var(--ink);
  }

  .sidebar__panel {
    display: none;
    max-height: 60vh;
    overflow-y: auto;
    padding-bottom: var(--gap-4);
  }

  .sidebar.is-open .sidebar__panel { display: block; }
  .sidebar.is-open .sidebar__caret { transform: rotate(180deg); }

  .content { padding: var(--gap-5) 0; }

  /* The stage keeps the full width it had; everything else regains its pad. */
  [data-kind="example"] .layout { grid-template-columns: none; }
  .site-nav { gap: var(--gap-3); overflow-x: auto; scrollbar-width: none; }
  .site-nav::-webkit-scrollbar { display: none; }

  .prevnext { flex-wrap: wrap; }
  .prevnext__index {
    display: block;
    width: 100%;
    order: 3;
    padding-top: var(--gap-4);
    color: var(--ink-dim);
    font-size: 0.875rem;
    text-decoration: none;
  }
  .prevnext__index:hover { color: var(--red); }

  /* Bottom sheet rather than a corner panel, and not resizable by drag. */
  .viewport {
    top: auto;
    right: 0;
    bottom: 0;
    left: 0;
    width: 100%;
    height: min(60vh, 420px);
    border-width: 1px 0 0;
    border-radius: 0;
  }

  .viewport__resize { display: none; }
  .site-footer__inner { grid-template-columns: 1fr; gap: var(--gap-5); }
}

@media (max-width: 560px) {
  .page-head h1 { font-size: 1.75rem; }

  /* The mark still links home. Dropping the word buys the nav the ~85px it
     needs to fit five links without clipping the last one. */
  .brand__word { display: none; }
  .site-nav__link { font-size: 0.875rem; }
  .hero__plate { padding: var(--gap-3); }

  .prevnext { flex-direction: column; }
  .prevnext__link--next { margin-left: 0; text-align: left; }
}
