/* Default design tokens — engine base layer. A consuming site's theme.css is
   appended after this by the build, so any token here can be overridden there;
   but the engine ships a complete, self-sufficient default palette so a site
   with no (or a partial) theme still renders legibly. Colors are system-color
   based (Canvas/CanvasText/…) and adapt to the user's light/dark preference. */
:root {
  color-scheme: light dark;

  --bg: Canvas;
  --fg: CanvasText;
  --muted: color-mix(in srgb, CanvasText 60%, Canvas);
  --dim: GrayText;
  --accent: rgb(114, 243, 114);
  --accent-2: oklch(from var(--accent) l c calc(h + 180));
  --warning: Mark;

  --accent-dim: color-mix(in srgb, var(--accent) 60%, var(--bg));
  --border: color-mix(in srgb, CanvasText 30%, Canvas);
  --border-tree: color-mix(in srgb, CanvasText 15%, Canvas);
  --border-tag: color-mix(in srgb, CanvasText 40%, Canvas);
  --surface: color-mix(in srgb, CanvasText 8%, Canvas);
  --surface-2: color-mix(in srgb, CanvasText 5%, Canvas);
  --highlight: color-mix(in srgb, CanvasText 12%, Canvas);

  /* Doubled: a bare `monospace` makes browsers scale the size by 13/16. */
  --mono: monospace, monospace;

  --indent: 1.1rem;
  --bullet-w: 1rem;
  /* Sizes for mask-drawn chrome marks.

     --mark-size: the show-hidden tick. Cells holding one derive their box from
     it, so overriding rescales mark and cell together and cannot leave the
     mark overhanging its cell.
     --chip-icon-*: footer chip icons (search loupe, view menu). Both chips
     read these, so they stay identical to each other through any override —
     they sit side by side, where a mismatch reads as a bug.

     These are em, i.e. the font's design size, which is taller than any actual
     letter — so a mark at 1em is somewhat larger than the capitals it sits
     beside. If you want a mark that instead tracks the height of neighbouring
     text across a font change, `1cap` is the unit that means that (needs an em
     fallback behind @supports (width: 1cap), since an unparsed unit invalidates
     the declaration and yields a zero-size mask). */
  --mark-size: 0.8em;
  --chip-icon-size: 1em;
  --chip-icon-shift: -0.15em;
  --tree-gap: 0.3rem;
  --tree-indent-offset: 0.4rem;
  --body-border-w: 2px;
  --md-fade-h: 2rem;
  --node-max-w: 600px;

  /* Keymap panel. --keymap-viewport-inset is the gap it keeps from the
     viewport edge; both the width and height caps are expressed against it. */
  --keymap-max-w: 32rem;
  --keymap-viewport-inset: 2rem;
  --keymap-pad: 1rem 1.25rem;
  --keymap-row-gap: 0.25rem;
  --keymap-col-gap: 0.75rem;
  --keymap-section-gap: 1.1rem;
  --keymap-title-gap: 0.35rem;
  --keymap-radius: 4px;
  /* em, so a keycap tracks the text it sits in rather than a fixed step. */
  --keycap-size: 0.85em;
  --keycap-pad: 0.05rem 0.3rem;
  --keycap-radius: 3px;
  --keymap-join-size: 0.9em;
  --keymap-join-gap: 0.25em;
  --keymap-backdrop: transparent;
  --keymap-backdrop-blur: 3px;

  /* Type sizes, named for the role rather than the measurement.

     Three steps below body, not six. The literals these replaced ran 0.68,
     0.78, 0.82, 0.85, 0.88 and 0.9rem — but 0.82 through 0.9 is 13.1px to
     14.4px at a 16px root: four "sizes" spanning 1.3px, which no reader can
     tell apart. That was drift between edits made at different times, not a
     scale, and collapsing it loses no distinction that was ever visible.

     What survives is what can actually be told apart: chrome text, annotations
     clearly smaller than it, and one step between for the labels on a panel's
     own furniture, which should recede without shrinking to the size of a tag.

     Only absolute sizes are tokens. A rule that sizes itself against its parent
     — code inside a label, ruby annotations — stays in em, because what it
     wants is "smaller than whatever contains me", not a fixed step. */
  --text-annotation: 0.68rem;
  /* node ids, tags */
  --text-affordance: 0.78rem;
  /* labels on panel furniture, e.g. a download link */
  --text-chrome: 0.85rem;
  /* footer, toggles, captions, hints, body chrome */

  /* The markdown heading ladder: a real descending scale, and rendered content
     rather than interface, so it is kept apart from the chrome sizes above. It
     bottoms out at --text-chrome, an h6 already being as small as the
     interface's own text. */
  --text-h2: 1.25rem;
  --text-h3: 1.1rem;
  --text-h4: 1rem;
  --text-h5: 0.92rem;
  --text-h6: var(--text-chrome);

  /* ── Chrome icons ──
     The engine's own UI markers, as inline SVG data: URIs. They are inlined
     rather than fetched because chrome must render before (and despite) any
     network activity — the error marker in particular has to survive the case
     where the network is what failed.

     These were glyphs ('▶', '≡', '⌕', '−', '•', '×'). As characters they only
     drew correctly if the reader had a font covering them, which is what pushed
     sites into shipping symbol/emoji font stacks. As masked SVG they render
     identically everywhere, inherit currentColor, and cost no font.

     Single-quoted so the inner markup can use double quotes; '#' is %23-escaped
     because it would otherwise start a URL fragment. */
  /* Equilateral, and drawn so its CENTROID sits at the viewBox centre (8,8) —
     not its bounding box. A triangle's centroid is 1/3 of the altitude from the
     base, so a bounding-box-centred triangle visibly swings when rotated. With
     the centroid centred, `transform-origin: center center` turns it in place,
     and every rule that rotates this icon gets that for free. */
  --icon-toggle: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M5.5 3.67L13 8L5.5 12.33Z"/></svg>');
  --icon-bullet: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.2"/></svg>');
  --icon-menu: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 3.6h12v1.6H2zM2 7.2h12v1.6H2zM2 10.8h12v1.6H2z"/></svg>');
  --icon-search: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M7 1.5a5.5 5.5 0 103.4 9.8l3.4 3.4 1.1-1.1-3.4-3.4A5.5 5.5 0 007 1.5zm0 1.6a3.9 3.9 0 110 7.8 3.9 3.9 0 010-7.8z"/></svg>');
  --icon-collapse: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3 7.2h10v1.6H3z"/></svg>');
  /* Same bar as --icon-collapse, plus its vertical twin — so + and − read as one
     pair rather than two different weights. */
  --icon-expand: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3 7.2h10v1.6H3zM7.2 3h1.6v10H7.2z"/></svg>');
  --icon-tick: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M12.7 4.4l-1.1-1.1L8 6.9 4.4 3.3 3.3 4.4 6.9 8l-3.6 3.6 1.1 1.1L8 9.1l3.6 3.6 1.1-1.1L9.1 8z"/></svg>');
  /* Checkbox marker (§6): a box, not the disclosure's triangle — the two
     mechanisms must not look identical while behaving differently. */
  --icon-box: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3 3h10v10H3zm1.5 1.5v7h7v-7z"/></svg>');
  --icon-box-checked: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3 3h10v10H3zm1.5 1.5v7h7v-7z"/><path d="M5.5 8.1l1.9 1.9 3.2-3.9 1.1 0.9-4.2 5.1L4.5 9.2z"/></svg>');
  --icon-error: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 1.5a5.5 5.5 0 013.6 1.35L3.85 11.6A5.5 5.5 0 018 2.5zm0 11a5.5 5.5 0 01-3.6-1.35l7.75-7.75A5.5 5.5 0 018 13.5z"/></svg>');
}

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

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

html,
body {
  background: var(--bg);
  color: var(--fg);
  height: 100vh;
  height: 100dvh;
  /* dynamic viewport height — adjusts for mobile browser chrome */
  overflow-x: auto;
  overflow-y: hidden;
  padding: 1rem;
}

/* ── Root layout ── */
#root {
  max-width: 960px;
  width: 100%;
  height: 100%;
  margin: 0 auto;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

header,
footer {
  padding-left: 1.5rem;
  padding-right: 1.5rem;
  flex-shrink: 0;
}

header {
  padding-top: 1rem;
}

footer {
  padding-bottom: 1rem;
}

#tree-scroll {
  flex: 1 1 0;
  overflow-x: auto;
  overflow-y: auto;
  padding-bottom: 1rem;

  /* The tree column is a size container — node widths and any "narrow column"
     responsive rules below trigger off this, not the viewport. This is what
     makes the tree reflow when a sidepanel takes half the screen. */
  container-type: inline-size;
  container-name: tree;
  --node-w: min(var(--node-max-w), 100cqi - 1.5rem);
}

/* ── Header ── */
header {
  margin-bottom: 1rem;
  border-bottom: 1px solid var(--border);
  padding-bottom: 0.6rem;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
}

header[hidden] {
  display: none;
}

header h1 {
  color: var(--fg);
}

/* ── Footer ── */
footer {
  margin-top: 1rem;
  font-size: var(--text-chrome);
  color: var(--muted);
  text-align: center;
}

/* ── Tree chrome ── */
.tree {
  list-style: none;
  padding: 0 0.75rem;
}

.node {
  position: relative;
}

.node-content {
  display: flex;
  align-items: baseline;
  gap: var(--tree-gap);
  padding: 0.15rem 0;
  cursor: default;
  width: var(--node-w);
  flex-shrink: 0;
}

.node-content:hover .node-id {
  opacity: 1;
}

/* ── Toggle bullet ── */
/* Only the engine's own default markers ('•', '▶') are drawn as text here, and
   both are in every monospace face — so this stack needs no symbol/emoji fonts.
   Author-supplied markers are images (see --node-bullet-image below), which is
   what lets a site ship no font stack for bullets at all. */
.toggle::before {
  font-family: var(--mono, monospace);
}

.toggle {
  position: relative;
  flex-shrink: 0;
  width: var(--bullet-w);
  font-size: var(--text-chrome);
  color: var(--muted);
  /* No cursor here: see .bullet-clickable below — the pointer is asserted only
     where a click actually does something. */
  user-select: none;
  display: inline-block;
  text-align: center;
}

/* The bullet glyph is what spins on expand — see the rotation rules below.
   inline-block so the transform applies; the transition lives here rather than
   on .toggle so only the glyph animates. */
.toggle::before {
  display: inline-block;
  transition: transform 0.15s;
}

/* Symbol hierarchy — all visual, no JS involvement */
/* aria-expanded lives on .node-content (the treeitem) */
/* Markers are masked SVG (see --icon-* in :root), not glyphs, so the tree needs
   no symbol font to draw its own chrome. */
.toggle::before {
  content: '';
  width: 1em;
  height: 1em;
  vertical-align: -0.125em;
  background-color: currentColor;
  -webkit-mask: var(--icon-bullet) no-repeat center / contain;
  mask: var(--icon-bullet) no-repeat center / contain;
}

/* Expandable rows swap the dot for a triangle. It fills the full box: the
   dot/triangle contrast is the main "has children" signal in the tree, so the
   triangle needs to read as clearly bigger than a leaf's dot, not merely a
   different shape at the same weight. */
.node-content[aria-expanded]:not(.node-content--bullet-image) .toggle:not(.leaf)::before {
  -webkit-mask-image: var(--icon-toggle);
  mask-image: var(--icon-toggle);
}

/* Image bullets ({bullet: ./icons/tip.svg}). The ref is resolved against the
   node's OWN source file, so a transcluded foreign node paints its origin's
   icon — the glyph repertoire stays open across federation instead of being
   fixed by whatever this site happens to ship.

   Painted as a mask rather than a background so the icon inherits `color` the
   way the default marker does: it follows --muted, the :hover accent and focus
   states, and any per-node color, with no per-theme icon variants. The cost is
   that bullet icons are monochrome by design. The browser fetches the URL
   lazily — only when a rule actually paints this marker — so unused icons in a
   set are never requested.

   If the icon fails to load the handler drops the class and the node falls back
   to the default marker, so a dead URL never leaves an empty gutter. */
.node-content--bullet-image .toggle::before,
.node-content--bullet-image[aria-expanded] .toggle:not(.leaf)::before {
  /* Empty content: the mask paints the box itself, so any glyph here would show
     THROUGH the icon. */
  content: '';
  display: inline-block;
  width: 1em;
  height: 1em;
  vertical-align: -0.125em;
  /* currentColor is captured before the mask clips it, so the icon inherits the
     same colour the default marker would — muted, hover accent, focus. */
  background-color: currentColor;
  -webkit-mask: var(--node-bullet-image) no-repeat center / contain;
  mask: var(--node-bullet-image) no-repeat center / contain;
}

/* Open-state icon ({bullet-open: ./icons/compass-open.svg}). The marker itself
   carries the open/closed state, so the corner badge is suppressed the way
   {bullet-spins} suppresses it — two state cues on one marker is one too many.

   Only the mask image changes: the box, the colour and the transition are the
   closed icon's, so the two icons must be drawn to the same optical weight or
   the row will appear to shift on expand. A mask-image swap is not animatable,
   so this is an instant cut — which is also why it needs no reduced-motion
   handling.

   Non-expandable rows never reach this rule (:not(.leaf) + [aria-expanded]), so
   a bullet-open on a childless node is simply inert rather than stuck open. */
.node-content--bullet-open[aria-expanded="true"] .toggle:not(.leaf)::before,
details[open]>.node-content--bullet-open .toggle:not(.leaf)::before {
  -webkit-mask-image: var(--node-bullet-image-open);
  mask-image: var(--node-bullet-image-open);
}

/* ── Toggle badge ──
   The subscript ▶ marking an expandable node whose bullet is a custom icon.
   Hidden by default; the rules below reveal it where it applies.

   TWO elements on purpose (see makeToggleBadge in handler-utils.ts). The badge
   is a masked shape that needs a 1px ring to stay legible over the icon it
   overlaps, and `filter` is applied BEFORE `mask` on the same element — a
   drop-shadow on the masked box is computed on the unmasked square and then
   clipped away, drawing nothing. So:
     .toggle-badge      — position + filter (the ring)
     .toggle-badge::before — mask + rotation
   The filter therefore sees the finished, rotated, masked triangle.

   The ring is an alpha outset, not a painted halo. A halo would have to match
   whatever is behind the badge, and a node's background is author-controlled —
   it can be --bg, --surface-2 when selected/focused, or an image. An outset
   assumes nothing about the backdrop.

   No geometry is duplicated: .toggle-badge owns the box, its ::before is
   inset:0, so the two are the same rectangle in the same coordinate space. */
.toggle-badge {
  display: none;
}

.node-content--bullet-image[aria-expanded]:not(.node-content--bullet-spins):not(.node-content--bullet-open) .toggle:not(.leaf)>.toggle-badge {
  display: block;
  position: absolute;
  width: 0.75em;
  height: 0.75em;
  bottom: -0.05em;
  right: -0.2em;
  /* 1px ring in four directions. Hard-edged (0 blur) so it reads as a hairline
     separation rather than a glow. --row-bg tracks whatever the row is actually
     painted with (see the selection/focus rules), because a ring in the wrong
     colour is a visible outline rather than a gap. */
  filter: drop-shadow(1px 0 0 var(--row-bg, var(--bg))) drop-shadow(-1px 0 0 var(--row-bg, var(--bg))) drop-shadow(0 1px 0 var(--row-bg, var(--bg))) drop-shadow(0 -1px 0 var(--row-bg, var(--bg)));
}

.toggle-badge::before {
  content: '';
  position: absolute;
  inset: 0;
  background-color: currentColor;
  -webkit-mask: var(--icon-toggle) no-repeat center / contain;
  mask: var(--icon-toggle) no-repeat center / contain;
  transition: transform 0.15s;
}

/* The triangle is centred in its own mask box, so it pivots about the box
   centre. Rotation lives on the inner element, together with the mask, so the
   ring on the parent follows the rotated shape. */
.node-content--bullet-image[aria-expanded="true"]:not(.node-content--bullet-spins):not(.node-content--bullet-open) .toggle:not(.leaf)>.toggle-badge::before,
details[open]>.node-content--bullet-image:not(.node-content--bullet-spins):not(.node-content--bullet-open) .toggle:not(.leaf)>.toggle-badge::before {
  transform: rotate(90deg);
  transform-origin: center center;
}

@media (prefers-reduced-motion: reduce) {
  .toggle-badge::before {
    transition: none;
  }
}

/* Ordered-list items ({.li}). Every list (.tree) owns a counter scoped to
   itself, so sibling lists each restart at 1. The number replaces the bullet;
   nested .li lists count independently. Unconditional rather than
   :has(> .node > .node-content.li): that restyled every ancestor list on each
   expand, and a counter nothing increments costs nothing. */
.tree {
  counter-reset: rvmark-li;
}

.node-content.li {
  counter-increment: rvmark-li;
}

/* Numbered items draw text, not an icon — undo the mask box from .toggle::before
   so the counter is painted as a glyph again (digits need no symbol font). */
.node-content.li .toggle::before,
.node-content.li[aria-expanded] .toggle:not(.leaf)::before {
  content: counter(rvmark-li) '.';
  width: auto;
  height: auto;
  background-color: transparent;
  -webkit-mask: none;
  mask: none;
}

/* Number markers stay right-aligned to the label and grow the box when the
   ordinal outgrows the default bullet width, pushing the label aside instead of
   overlapping it. min-width keeps short numbers aligned with sibling bullets. */
.node-content.li .toggle {
  width: auto;
  min-width: var(--bullet-w);
  text-align: right;
}

/* {li: N} sets this item to N and lets the list continue from there. Uses
   counter-set (not a second counter-reset, which would open a competing counter
   scope and mis-number following siblings); counter-set writes the value in
   place and the item's own increment does not double-count it. --li-start is
   written as a validated integer by the text handler. */
.node-content.li[style*="--li-start"] {
  counter-set: rvmark-li var(--li-start);
}

/* Numbered items get the same badge (the counter replaces the toggle triangle,
   so the affordance moves to the corner). Same two-element structure — see the
   .toggle-badge block above. */
.node-content.li[aria-expanded] .toggle:not(.leaf)>.toggle-badge {
  display: block;
  position: absolute;
  width: 0.75em;
  height: 0.75em;
  bottom: -0.05em;
  right: -0.2em;
  filter: drop-shadow(1px 0 0 var(--row-bg, var(--bg))) drop-shadow(-1px 0 0 var(--row-bg, var(--bg))) drop-shadow(0 1px 0 var(--row-bg, var(--bg))) drop-shadow(0 -1px 0 var(--row-bg, var(--bg)));
}

.node-content.li[aria-expanded="true"] .toggle:not(.leaf)>.toggle-badge::before,
details[open]>.node-content.li .toggle:not(.leaf)>.toggle-badge::before {
  transform: rotate(90deg);
  transform-origin: center center;
}

/* Hover accent is asserted by .bullet-clickable (see the bullet-column rules
   below), not here: a bullet should only light up under the pointer if the
   click does something. */
/* The expand spin is applied to the bullet glyph (::before), not to .toggle
   itself. .toggle stays a stable, unrotated frame so markers positioned
   against it — the ::after badge below, and search's .search-indicator — keep
   their corner instead of being swung around the bullet for the duration of
   every expand/collapse transition. */
.node-content[aria-expanded="true"]:not(.node-content--bullet-image):not(.li) .toggle:not(.leaf)::before,
details[open]>.node-content:not(.node-content--bullet-image):not(.li) .toggle:not(.leaf)::before,
/* {bullet-open} wins over {bullet-spins} where an author sets both: swapping to
   a drawn open-state icon is the more specific intent, and spinning it too would
   rotate an icon that already reads as open. */
.node-content[aria-expanded="true"].node-content--bullet-spins:not(.node-content--bullet-open) .toggle:not(.leaf)::before,
details[open]>.node-content.node-content--bullet-spins:not(.node-content--bullet-open) .toggle:not(.leaf)::before {
  transform: rotate(90deg);
}

/* Readers who ask for less motion get the spin as an instant state change: the
   bullet still turns ▶ → ▼ so the open/closed distinction survives, it just does
   not sweep. Both markers are covered — the ::before glyph and the ::after
   subscript badge — since they rotate together and leaving one animated would
   only half-honour the request.

   Scoped to the transform transitions on purpose. The colour and opacity
   transitions elsewhere in this file (link hover, permalink and sidepanel-close
   reveals) move nothing on screen, and stripping them would make the UI feel
   abrupt without helping anyone this query is meant to protect.

   The engine's other motion is smooth scrolling, which CSS cannot reach from
   here — see scrollBehavior() in scroll.ts. */
@media (prefers-reduced-motion: reduce) {

  .toggle::before,
  .node-content--bullet-image[aria-expanded]:not(.node-content--bullet-spins) .toggle:not(.leaf)::after,
  .node-content.li[aria-expanded] .toggle:not(.leaf)::after {
    transition: none;
  }
}

/* A leaf bullet is muted and unclickable by default — but a leaf that clears a
   listbox is still a target, so .bullet-clickable below re-asserts the pointer.
   :not() here rather than relying on source order. */
.toggle.leaf:not(.bullet-clickable) {
  cursor: default;
}

.toggle.leaf {
  color: var(--muted);
}

/* Focus recolours the bullet, but must not make it look clickable: whether it
   is depends on whether anything consumes the click, which only the handler
   knows. wireBulletActions (handler-utils.ts) sets .bullet-clickable when a
   bullet expands or clears a listbox; that class is the single source of the
   pointer, here and for .tr-toggle / .table-toggle below. */
.node-content:focus .toggle {
  color: var(--accent);
}

.toggle.bullet-clickable,
.block-body-reset.bullet-clickable {
  cursor: pointer;
}

/* Hover accent, for every bullet-column target that actually takes a click.
   .toggle.leaf is included deliberately: a non-expandable listbox bullet is
   still clickable (it clears the selection), which the old .toggle.leaf:hover
   override used to suppress. */
.toggle.bullet-clickable:hover {
  color: var(--accent);
}

/* The block strip's own hover accent lives with the .block-body focus rule below,
   which it has to outrank. */

/* ── Label ── */
.node-label {
  flex: 1;
  line-height: 1.5;
  word-break: break-word;
}

.node-label strong {
  font-weight: 700;
}

.node-label em {
  font-style: italic;
}

.node-label a {
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.node-label code {
  background: var(--surface);
  padding: 0 3px;
  border-radius: 2px;
  font-family: var(--mono, monospace);
  font-size: 0.85em;
}

.node-label rt {
  font-size: 0.7em;
}

.node-label .vinculum {
  text-decoration: overline;
}

.node-label .vinculum .vinculum {
  text-decoration: overline double;
}

.node-label .strikethrough {
  text-decoration: line-through;
}

/* ── Conditional inline spans ({show-when}) ── */
/* Pending is the pre-wiring state stamped by the renderer, hidden the resolved
   one. Both use !important because the display they override is set by
   context-specific rules of higher specificity (.badges .inline-option, the
   .tabs strip), and a hidden span must stay hidden in every such context. */
.span-conditional-pending,
.span-hidden {
  display: none !important;
}

/* ── Inline options (listbox) ── */
.inline-option {
  color: var(--muted);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}

.inline-option[aria-selected="true"] {
  color: var(--fg);
}

/* ── Inline toggles (action-driven spans) ── */
/* Same visual vocabulary as the node bullet — the masked --icon-toggle glyph,
   rotating on expand.

   Dotted underline rather than italic: a citation is a reference, and italics
   fight the surrounding prose where titles and emphasis already use them.
   Distinct from .inline-option's solid underline — the two kinds behave
   differently, so they should not look identical. Same underline offset, so
   the two sit on a common baseline where they share a label.

   The label stays --fg in both states. It is prose — a citation or a phrase the
   sentence is built around — and muting it dims the reading text itself rather
   than the affordance. Nothing recolours on expand: the marker turns, and that
   is the whole state change, as in the tree. */
.inline-toggle {
  color: var(--fg);
  text-decoration: underline dotted;
  text-underline-offset: 2px;
  cursor: pointer;
  /* The marker is a separate inline box sitting in front of the label, so a
     line break can otherwise fall between them and strand the glyph at the end
     of a line, away from what it opens. Keeping the whole toggle on one line
     also stops the underline being cut in two mid-citation. */
  white-space: nowrap;
}

.inline-toggle:focus,
.inline-toggle:focus-visible {
  outline: none;
}

/* Same size as the node bullet's glyph: 1em at --text-chrome, so both markers
   are the same mark at the same scale wherever they appear. Sizing off the
   chrome token rather than the surrounding prose also keeps the box off
   fractional pixels — the glyph's flat back edge is a hard vertical line, and
   at a fractional size it antialiases into a stray sliver beside the arrow.

   Muted in every state, matching .toggle: same mark, same job, same colour, and
   open/closed carried by the rotation alone. An explicit colour rather than
   currentColor — the label is --fg, and the marker should not follow it. */
.span-toggle-marker {
  display: inline-block;
  width: var(--text-chrome);
  height: var(--text-chrome);
  margin-right: 0.15em;
  vertical-align: -0.12em;
  background-color: var(--muted);
  -webkit-mask: var(--icon-toggle) no-repeat center / contain;
  mask: var(--icon-toggle) no-repeat center / contain;
  transition: transform 0.15s;
}

.inline-toggle[aria-expanded="true"]>.span-toggle-marker {
  transform: rotate(90deg);
}

/* Focus shows on the marker itself rather than as a ring around the span: the
   span sits inline in prose, where an outline boxes a run of text mid-sentence
   and breaks the line. */
.inline-toggle:focus>.span-toggle-marker,
.inline-toggle:focus-visible>.span-toggle-marker {
  background-color: var(--accent);
}

@media (prefers-reduced-motion: reduce) {
  .span-toggle-marker {
    transition: none;
  }
}

/* ── Checkbox spans: targetless {toggle} ── */
.span-checkbox-marker {
  display: inline-block;
  width: var(--text-chrome);
  height: var(--text-chrome);
  margin-right: 0.15em;
  vertical-align: -0.12em;
  background-color: var(--muted);
  -webkit-mask: var(--icon-box) no-repeat center / contain;
  mask: var(--icon-box) no-repeat center / contain;
}

.inline-checkbox[aria-checked="true"]>.span-checkbox-marker {
  -webkit-mask-image: var(--icon-box-checked);
  mask-image: var(--icon-box-checked);
  background-color: var(--accent);
}

/* Focus on the marker, not a ring around the span — see .inline-toggle above. */
.inline-checkbox:focus>.span-checkbox-marker,
.inline-checkbox:focus-visible>.span-checkbox-marker {
  background-color: var(--accent);
}

.inline-checkbox {
  color: var(--fg);
  text-decoration: underline dotted;
  text-underline-offset: 2px;
  cursor: pointer;
  /* Same reason as .inline-toggle: keep marker and label on one line. */
  white-space: nowrap;
}

.inline-checkbox:focus,
.inline-checkbox:focus-visible {
  outline: none;
}

/* ── .badges: inline listbox styled as 88x31 badge strip ── */
.node-content.badges {
  align-items: center;
}

.node-content.badges .node-label[role="listbox"] {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.node-content.badges .inline-option {
  display: block;
  text-decoration: none;
  cursor: pointer;
  border-radius: 2px;
  line-height: 0;
}

.node-content.badges .inline-option:hover {
  outline: 1px solid var(--border);
  outline-offset: 1px;
}

.node-content.badges .inline-option[aria-selected="true"] {
  outline: 1px solid var(--muted);
  outline-offset: 1px;
}

.node-content.badges .inline-option img {
  display: block;
  image-rendering: pixelated;
  width: 88px;
  height: 31px;
}

/* ── .tabs: inline listbox styled as tab strip ── */
.node-content.tabs .node-label[role="listbox"] {
  display: flex;
  flex-wrap: wrap;
  gap: 0;
}

.node-content.tabs .inline-option {
  padding: 0.1rem 0.6rem;
  border-bottom: 2px solid var(--border-tree);
  text-decoration: none;
  font-size: var(--text-chrome);
  color: var(--muted);
  transition: color 0.1s, border-color 0.1s;
  cursor: pointer;
}

.node-content.tabs .inline-option:hover {
  color: var(--fg);
  border-bottom-color: var(--border);
}

.node-content.tabs .inline-option[aria-selected="true"] {
  color: var(--fg);
  border-bottom-color: var(--muted);
}

/* ── Permalink ── */
.node-id {
  font-size: var(--text-annotation);
  color: var(--dim);
  opacity: 0;
  transition: opacity 0.15s;
  text-decoration: none;
  margin-left: 0.3rem;
  padding: 0 0.2rem;
  flex-shrink: 0;
}

.node-id:hover {
  color: var(--accent);
}

/* ── Children container ── */
.node-children {
  display: none;
  --children-indent: calc(var(--bullet-w) + var(--tree-gap));
}

/* The indent sits on the child rows, not on this container, so the container
   can dissolve when open (below) without the rows losing it. */
.node-children>.tree>.node {
  margin-left: var(--children-indent);
}

/* A sibling combinator, not .node:has(> …): :has() on the li makes every
   ancestor li re-match its whole subtree on each expand.

   display:contents leaves two boxes per level instead of three. Firefox's
   reflow cost climbs steeply with box depth, and a recursive transclusion can
   be followed hundreds of levels down. The nested .tree must stay a box: its
   counter-reset is what restarts a nested {li} list. */
.node>.node-content[aria-expanded="true"]~.node-children {
  display: contents;
}

/* Static fallback: <details> already hides its own content when closed, so the
   .node-children default above would keep it hidden when open. The disclosure
   owns visibility here; the div is only a layout wrapper. */
details>.node-children {
  display: block;
}

/* An always-open type (block) or `open: always` gets no disclosure in the
   fallback — its children sit beside the row and are never hidden, so nothing
   would ever flip the default display:none for them. Marked on the li at build
   time.

   A class rather than #static-content: an id selector would outrank the
   :checked rules that drive the table family's collapse further down, forcing
   every table open. */
.node.static-always-open>.node-children {
  display: block;
}

/* The row inside a disclosure is a <summary>, which defaults to
   display:list-item to carry the native disclosure triangle. .node-content's
   own display:flex already replaces that layout, and the three rules here
   clear the marker itself across engines: list-style for the modern path,
   ::marker as a belt-and-braces for engines that keep a marker box under
   flex, and ::-webkit-details-marker for older Safari, which used a
   pseudo-element rather than ::marker. The tree draws its own bullet in
   .toggle, so a second marker would be a duplicate. */
summary.node-content {
  list-style: none;
}

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

summary.node-content::marker {
  content: '';
}

/* A permalink into a collapsed subtree must land somewhere visible. Opening
   the targeted disclosure is not enough — every ancestor is closed too — so
   :has() matches any disclosure containing the target, which is the whole
   chain. Without this a deep '#id' link in the fallback scrolls to content the
   browser is still hiding.
   The [open] attribute is untouched: this forces the content visible, so the
   reader can still collapse the node afterwards. */
details:target>.node-children,
details:has(:target)>.node-children {
  display: block;
}

/* ── Nested tree ── */
.tree .tree {
  padding: 0;
}

.node-children>*+.tree {
  margin-top: 0.5rem;
}

/* ── Selection & focus highlight ── */
/* aria-selected persists when focus moves into the node's doc zone (children content). */
.node-content[aria-selected="true"] {
  background: var(--surface-2);
  border-radius: 3px;
  /* Whatever paints the row must also publish it here: the toggle badge's
     separation ring is drawn in --row-bg, and a ring in the wrong colour shows
     up as an outline instead of a gap. */
  --row-bg: var(--surface-2);
}

.node-content:focus {
  outline: none;
  background: var(--surface-2);
  border-radius: 3px;
  --row-bg: var(--surface-2);
}

.node-content:focus .toggle {
  color: var(--accent);
}

.node-content:focus .node-id {
  opacity: 1;
  color: var(--muted);
}

.node-content[aria-selected="true"] .node-id {
  opacity: 1;
}

/* ── Body mode indicator ── */
.node-content:has(.block-body-scroll:focus) {
  box-shadow: 0 0 0 1px var(--accent);
  border-radius: 3px;
}

.node-content:focus .block-body {
  border-left-color: var(--accent-dim);
}

/* The block node's reset strip paints nothing of its own — it is a bare hit
   area over the border — so hovering it accents that border instead, in the
   same --accent-dim the focused state uses: it is the same border reading as
   live, not a second, louder state. */
.block-body:has(> .block-body-reset.bullet-clickable:hover) {
  border-left-color: var(--accent-dim);
}

/* ── Minor nodes ── */
.node-content.minor .node-label {
  color: var(--muted);
}

/* ── Tag chips ── */
.node-tag {
  display: inline-block;
  font-size: var(--text-annotation);
  line-height: 1.2;
  padding: 0 5px;
  border: 1px solid var(--tag-color, var(--border-tag));
  border-radius: 3px;
  color: var(--tag-color, var(--muted));
  vertical-align: 0.05em;
  white-space: nowrap;
  flex-shrink: 0;
  text-decoration: none;
}

.node-tag--link,
.node-label .node-tag--link {
  cursor: pointer;
  color: var(--tag-color, var(--muted));
  text-decoration: none;
}

.node-tag--link:hover {
  background: var(--tag-color, var(--border-tag));
  color: var(--bg);
}

/* ── Footer links ── */
footer a {
  color: inherit;
  text-decoration: none;
}

/* Footer sections — break between interpuncts, not inside a section */
footer .footer-section {
  white-space: nowrap;
}

/* ── Visually hidden ──
   Text for screen readers only. Clipped to a 1px box rather than display:none
   or visibility:hidden, both of which remove the node from the accessibility
   tree along with the pixels — which would defeat the entire point. Used for
   bullet-alt names and the search status line. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* ── Show-hidden toggle (footer) ── */
.show-hidden-toggle {
  cursor: pointer;
  user-select: none;
}

/* Visually hidden but kept in tab order and accessible to screen readers.
   Same clip pattern as .visually-hidden above. */
.show-hidden-toggle input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* The brackets are static text; only the tick cell between them changes.

   Ticking never moves ' show hidden': the cell is the same box in both states
   and the mark is absolutely positioned, so no mark dimension reaches layout.

   Alignment is by half-leading, not a guessed vertical-align. The cell is an
   inline-block, so its box — not its (empty) text baseline — is what lines up
   against the brackets, and a hand-tuned baseline nudge only matches one font.
   Instead the cell is a full line-box tall (1lh) and the mark is centred inside
   it: an inline box of the line's own height sits exactly where the line's text
   sits, in any font, because that is the same box the brackets are centred in.
   1lh falls back to 1.4em on engines without it (see the fallback declaration).

   The mark is deliberately smaller than the cell (--mark-inset), so it nests
   between the brackets instead of running edge to edge into them. */
.show-hidden-toggle .tick {
  position: relative;
  display: inline-block;
  vertical-align: top;
  width: max(1ch, var(--mark-size));
  height: 1.4em;
  height: 1lh;
}

.show-hidden-toggle:has(input:checked) .tick::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: var(--mark-size);
  height: var(--mark-size);
  background-color: currentColor;
  -webkit-mask: var(--icon-tick) no-repeat center / contain;
  mask: var(--icon-tick) no-repeat center / contain;
}

/* Matches whether the label itself is the focus target (in the view menu, where
   it carries tabindex) or the checkbox inside it is. */
.show-hidden-toggle:focus-visible,
.show-hidden-toggle:has(input:focus-visible) {
  outline: 1px solid var(--accent);
  outline-offset: 1px;
}

/* ── View menu (footer drop-up) ── */
/* Opens upward: the footer is the last thing on the page, so a downward menu
   would open off-screen. */
.view-menu {
  position: relative;
  display: inline-block;
}

.view-menu>summary {
  cursor: pointer;
  user-select: none;
  list-style: none;
  /* No padding, matching .search-toggle: the ' · ' text nodes between footer
     chips already supply the spacing, and padding here added a gap the loupe
     didn't have. */
  padding: 0;
  line-height: 1;
  color: var(--muted);
}

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

/* Reads as "menu" rather than as a direction: the drop-up always opens the one
   way it can, so the arrow was spending its meaning on something the user has
   no choice about. Static: the mark is the affordance, so it must not move. */
/* Geometry comes from the shared chip-icon tokens, not literals, so this and
   the search loupe cannot drift apart under a theme override. */
.view-menu>summary::before {
  content: '';
  display: inline-block;
  width: var(--chip-icon-size);
  height: var(--chip-icon-size);
  vertical-align: var(--chip-icon-shift);
  background-color: currentColor;
  -webkit-mask: var(--icon-menu) no-repeat center / contain;
  mask: var(--icon-menu) no-repeat center / contain;
}

.view-menu>summary:hover {
  color: var(--accent);
}

.view-menu>summary:focus-visible {
  outline: 1px solid var(--accent);
  outline-offset: 1px;
}

.view-menu-items {
  position: absolute;
  bottom: calc(100% + 0.2rem);
  left: 50%;
  transform: translateX(-50%);
  z-index: 20;
  display: flex;
  flex-direction: column;
  min-width: max-content;
  padding: 0.15rem 0;
  line-height: 1.3;
  background: var(--bg);
  border: 1px solid var(--border);
  text-align: left;
}

/* Rows sit at the footer's own muted weight; hover lifts one to full contrast
   rather than every row shouting at once. */
.view-menu-items a {
  padding: 0.1rem 0.5rem;
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
}

/* Same highlight as a selected row in the tree. */
.view-menu-items a:hover,
.view-menu-items a:focus-visible {
  background: var(--surface-2);
  color: var(--fg);
  border-radius: 3px;
  outline: none;
}

/* The keymap row. A <button> inherits none of the ambient text styles by
   default, so font/color/alignment are restated to make it read as one of the
   menu's rows rather than as a control dropped into it. */
.view-menu-items .keymap-row {
  display: block;
  width: 100%;
  padding: 0.1rem 0.5rem;
  background: none;
  border: 0;
  font: inherit;
  text-align: left;
  white-space: nowrap;
  cursor: pointer;
  color: var(--muted);
}

.view-menu-items .keymap-row:hover,
.view-menu-items .keymap-row:focus-visible {
  background: var(--surface-2);
  color: var(--fg);
  border-radius: 3px;
  outline: none;
}

.view-menu-sep {
  border: 0;
  border-top: 1px solid var(--border);
  margin: 0.15rem 0;
}

/* ── Keymap dialog ── */
/* margin: auto is what centres a modal <dialog>; it restores what the global
   `* { margin: 0 }` reset above takes away. */
.keymap-dialog {
  margin: auto;
  padding: 0;
  width: max-content;
  max-width: min(var(--keymap-max-w), calc(100vw - var(--keymap-viewport-inset)));
  max-height: calc(100vh - var(--keymap-viewport-inset) * 2);
  overflow: auto;
  background: var(--bg);
  color: var(--fg);
  border: 1px solid var(--border);
  border-radius: var(--keymap-radius);
}

.keymap-dialog::backdrop {
  background: var(--keymap-backdrop);
  -webkit-backdrop-filter: blur(var(--keymap-backdrop-blur));
  backdrop-filter: blur(var(--keymap-backdrop-blur));
}

/* showModal() focuses the dialog itself (nothing inside is focusable), which
   would otherwise draw the global focus ring around the whole panel. */
.keymap-dialog:focus,
.keymap-dialog:focus-visible {
  outline: none;
}

/* One grid for the whole panel: sections and their <dl>s take these tracks via
   subgrid, so the keys column is measured across every section at once rather
   than per-section. */
.keymap-body {
  padding: var(--keymap-pad);
  display: grid;
  grid-template-columns: max-content 1fr;
}

.keymap-section {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 1 / -1;
}

.keymap-section+.keymap-section {
  margin-top: var(--keymap-section-gap);
}

.keymap-section-title {
  grid-column: 1 / -1;
  margin: 0 0 var(--keymap-title-gap);
  font-size: var(--text-chrome);
  font-weight: normal;
}

.keymap-rows {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 1 / -1;
  gap: var(--keymap-row-gap) var(--keymap-col-gap);
  margin: 0;
  align-items: baseline;
}

.keymap-keys {
  margin: 0;
  white-space: nowrap;
}

.keymap-desc {
  margin: 0;
}

/* Spacing lives on the joiner, not the keycaps: a margin on kbd would push the
   first one off the column's left edge. */
.keymap-join {
  margin: 0 var(--keymap-join-gap);
  font-size: var(--keymap-join-size);
}

.keymap-keys kbd {
  display: inline-block;
  padding: var(--keycap-pad);
  font: inherit;
  font-size: var(--keycap-size);
  line-height: 1.4;
  color: var(--fg);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--keycap-radius);
}

/* The show-hidden toggle keeps its [ ]/[×] marker, but sits as a menu row. */
.view-menu-items .show-hidden-toggle {
  padding: 0.1rem 0.5rem;
  white-space: nowrap;
  color: var(--muted);
}

.view-menu-items .show-hidden-toggle:hover,
.view-menu-items .show-hidden-toggle:focus-visible,
.view-menu-items .show-hidden-toggle:has(input:focus-visible) {
  background: var(--surface-2);
  color: var(--fg);
  border-radius: 3px;
  outline: none;
}

/* ── Search ── */
/* A footer chip, inline alongside the footer text and view menu (see
   #search-root in shell.ts's mountSearchRoot) — not a header control. The
   search input can be taller than a themed header's own h1, and whichever
   flex child is tallest sets a flex row's height, so living in header meant
   opening/closing search grew and shrank the whole header by that amount,
   shifting every bit of page content below it. footer sits after the
   flexible #tree-scroll, which absorbs any size change instead. */
#search-root {
  display: inline;
}

/* Plain inline, not flex: the toggle and input are both already inline-level
   with the footer's own inherited font metrics, so normal inline flow lays
   them out side by side on its own. Flex would also collapse the literal
   space character between them (see buildWidget in search.ts) — a flex
   container drops whitespace-only text-node children instead of rendering
   them, which is exactly the "every gap here is a real text-node space, not
   a CSS length" mechanism the rest of this footer relies on. */
.search-widget {
  display: inline;
}

/* The loupe stays put always — closed or open. Opening (.search-widget--active)
   only reveals the input beside it; it never replaces the icon, so there's
   always a stable, permanent footer affordance to click back into.
   Deliberately not given its own font-size/font-family/line-height: a
   <button> doesn't inherit ambient text styles by default (a UA-stylesheet
   quirk, not something particular to this page), which font: inherit here
   corrects — the glyph then renders at the same size/face/line-height as the
   footer text around it, the only way for it to actually land on the same
   line without a guessed offset. */
.search-toggle {
  display: inline-block;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  cursor: pointer;
  color: var(--muted);
}

/* Shared chip-icon tokens — see the view-menu's ::before. Both icons are
   0 0 16 16 masks with ink filling the box, so `contain` makes the box the
   rendered size and these two values are the whole story. */
.search-toggle::before {
  content: '';
  display: inline-block;
  width: var(--chip-icon-size);
  height: var(--chip-icon-size);
  vertical-align: var(--chip-icon-shift);
  background-color: currentColor;
  -webkit-mask: var(--icon-search) no-repeat center / contain;
  mask: var(--icon-search) no-repeat center / contain;
}

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

.search-toggle:focus-visible {
  outline: 1px solid var(--accent);
  outline-offset: 1px;
}

/* The loupe lights up only while a search is actually finding something —
   not merely because the bar is open, which the input's own presence already
   shows. Both halves of that condition are readable from the DOM, so this
   needs no state tracked in search.ts:
     :not(:placeholder-shown)      → a query has been typed
     :not(.search-input--no-matches) → that query found something
   (the latter is set by updateResultState(), which also dims the input). */
.search-widget--active:has(.search-input:not(:placeholder-shown):not(.search-input--no-matches)) .search-toggle {
  color: var(--fg);
}

/* .search-status is never display:none (see its own rule below) — it's
   already invisible via the clip-rect technique whether or not search is
   open, which is what keeps it announceable to assistive tech throughout. */
.search-input {
  display: none;
}

.search-widget--active .search-input {
  display: inline-block;
}

/* No box at all — reads as plain footer text being typed, not a form field.
   field-sizing: content grows/shrinks the input with what's typed (falling
   back to the UA default intrinsic width in browsers without it, still
   capped by max-width so a long query can't blow out the footer). No focus
   ring either: unlike a page full of other focusable controls, this is the
   only thing that can be focused right after clicking the loupe or pressing
   Ctrl+F, so there's nothing for a ring to disambiguate — over plain text
   with no border/background to contain it, it reads as a stray box instead. */
.search-input {
  font: inherit;
  font-size: inherit;
  color: inherit;
  background: none;
  border: none;
  padding: 0;
  outline: none;
  field-sizing: content;
  max-width: 40vw;
}

/* Active (focused, or has a query — same .search-widget--active condition
   the toggle icon reacts to) reads in the page's normal text color rather
   than the footer's own muted one, same as any other text being typed. */
.search-widget--active .search-input {
  color: var(--fg);
}

/* Zero matches. Dimming the reader's own query says "nothing found" without a
   label to read — the same channel the answer would have appeared in.
   Chained with .search-widget--active (matching its specificity) so this
   wins over the active-state color above regardless of source order —
   otherwise a query with no matches would still show in the active fg color,
   silently losing the only cue that nothing was found. */
.search-widget--active .search-input--no-matches {
  color: var(--muted);
}

/* The dimmed input is a visual-only cue, so the same fact is announced here.
   Visually hidden rather than display:none, which would not be announced. */
.search-status {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* .visually-hidden (above) is the same rule; .search-status keeps its own copy
   because it is also a live region and reads as one declaration there. */

/* Collapsed ancestor of an unmounted match — a breadcrumb, not a reveal.
   The reader decides whether to expand; search never does it for them.

   The superscript counterpart to the subscript ▶ badge above: same box, same
   corner, top instead of bottom, with the same halo so it stays legible over
   any bullet. Positioned against .toggle (which is position: relative) exactly
   as that badge is — the one anchor every layout agrees on, since .toggle is
   always --bullet-w wide with its bullet centred, while a table row's own
   .node generates no box at all (.tr-row is display: contents, so a dot
   positioned against it escapes to the table's grid container and lands beside
   the header rather than on its own row).

   Like that badge it sits inside .toggle, which is a stable frame: the expand
   spin is applied to the bullet glyph (.toggle::before), not to .toggle
   itself, so neither marker is swung around the bullet during the transition.
   .toggle is also the one anchor every layout agrees on — it is always
   --bullet-w wide with its bullet centred, while a table row's own .node
   generates no box at all (.tr-row is display: contents, so a marker
   positioned against it escapes to the table's grid container and lands beside
   the header rather than on its own row). */
.toggle>.search-indicator {
  position: absolute;
  top: 0.05em;
  right: -0.1em;
  width: 0.3em;
  height: 0.3em;
  border-radius: 50%;
  background: var(--accent);
  pointer-events: none;
  box-shadow: 0 0 0.2em var(--bg), 0 0 0.2em var(--bg), 0 0 0.2em var(--bg);
}

/* Matched substrings, styled like a browser's own find-in-page highlight, but
   tinted with the theme accent rather than the system Mark yellow. Accent at
   full strength is a saturated link color, so it's mixed into the background
   — the same way --surface/--highlight are derived. The mix ratio trades
   off two failure modes: too low and marks vanish against the selected-row
   background, too high and --fg text on top of them stops being legible.
   One consistent color for every match — no separate "current" tint tied to
   selection, since that would make marks change color as a side effect of
   selection changing for reasons unrelated to search (e.g. clicking
   elsewhere in the tree). */
.search-mark {
  background: color-mix(in srgb, var(--accent) 55%, var(--bg));
  color: var(--fg);
  border-radius: 2px;
}

/* ── Transclusion error ── */
/* A broken/timed-out transclusion renders as an ordinary row with a distinct
   marker and muted text. Drawn here rather than as a bullet attr so the engine's
   own error state needs neither a font nor a fetched icon — it must render even
   when the network is what failed. */
.node-load-error {
  color: var(--muted);
}

/* Crossed circle (⨂), inlined as a data: URI rather than fetched: this marker
   has to render when the network is exactly what failed. Masked like every other
   icon, so it still inherits currentColor. */
.node-load-error .toggle::before,
.node-load-error[aria-expanded] .toggle:not(.leaf)::before {
  content: '';
  display: inline-block;
  width: 1em;
  height: 1em;
  vertical-align: -0.125em;
  background-color: currentColor;
  transform: none;
  -webkit-mask: var(--icon-error) no-repeat center / contain;
  mask: var(--icon-error) no-repeat center / contain;
}

/* ── Static content (no-JS fallback) ──
   The fallback renders the SAME markup as the hydrated tree — .node,
   .node-content, .toggle, .node-label, .node-tag, .node-children — so
   everything above styles it already. What lives here is only what the two
   renderings genuinely do not share.

   The one structural difference: an expandable static row is a <summary>
   inside <details> (native, JS-free disclosure), where the hydrated row is a
   <div> whose aria-expanded the engine drives. See wrapDisclosure in
   build-rvmark.mjs, and the details[open] companions on the open-state rules
   above. */
#static-content {
  flex: 1 1 0;
  overflow-y: auto;
  padding: 0 0.75rem 1rem;

  /* Same container setup as #tree-scroll so static nodes reflow on narrow columns */
  container-type: inline-size;
  container-name: tree;
  --node-w: min(var(--node-max-w), 100cqi - 1.5rem);
}

#static-content>.tree>li+li {
  margin-top: 1em;
}

/* Hidden nodes. Unlike the hydrated page there is no show-hidden toggle to
   reveal these — nothing in the fallback drives runtime state — so {hidden}
   is unconditional here. */
.static-hidden {
  display: none;
}

/* The bullet column is a real target in the fallback (it is inside the
   <summary>), so it gets the pointer the hydrated page asserts via
   .bullet-clickable — which is set by JS that never runs here. */
summary.node-content .toggle:not(.leaf) {
  cursor: pointer;
}

summary.node-content:hover .toggle:not(.leaf) {
  color: var(--accent);
}

/* Transclusion references — distinct from regular links */
a.static-ref {
  color: var(--muted);
  text-decoration: none;
}

a.static-ref:hover {
  text-decoration: underline;
}

/* Interpage transclusions — visually distinct from in-page jumps */
a.static-ref--interpage {
  color: var(--accent);
  text-decoration: underline dotted;
  text-underline-offset: 3px;
}

/* Sidepanel handle — small icon link to the underlying asset */
a.static-sidepanel-link {
  color: var(--muted);
  text-decoration: none;
  font-size: 0.85em;
  margin-left: 0.25em;
}

a.static-sidepanel-link:hover {
  color: var(--accent);
}

/* URL-mode iframe — plain link to the embedded page */
a.static-iframe-link {
  color: var(--accent);
  text-decoration: underline dotted;
  text-underline-offset: 3px;
  font-family: var(--mono, monospace);
  font-size: 0.85em;
}

/* ── Divider ── */
.node-content--hr {
  margin-left: calc(var(--bullet-w) + var(--tree-gap));
  width: calc(var(--node-w) - var(--bullet-w) - var(--tree-gap));
}

.divider-rule {
  border: none;
  border-top: 1px solid var(--border);
  margin: 0.2rem 0;
  width: 100%;
}

/* ── Gap ── */
/* Vertical whitespace between siblings — no line, no content. */
.node-content--gap {
  height: 0.4rem;
}

/* ── Narrow page (device-level chrome) ── */
@media (max-width: 768px) and (orientation: portrait) {

  html,
  body {
    padding: 0.5rem;
  }

  header,
  footer {
    padding-left: 0.75rem;
    padding-right: 0.75rem;
  }
}

/* ── Narrow tree column (driven by container width, not viewport) ──
   Fires whether the column is narrow because the viewport is small or
   because a sidepanel/sidepanel has squeezed the tree column. */
@container tree (max-width: 600px) {
  .tree .tree {
    padding: 0;
  }

  .block-body {
    margin-left: 0;
  }
}

/* ── Hide header on mobile ── */
@media (max-width: 768px) and (orientation: portrait),
(orientation: landscape) and (max-height: 540px) {
  header {
    display: none !important;
  }
}

/* ── rvmark iframe guest (embedded as iframe node in another rvmark page) ── */
html.rvmark-iframe-guest,
html.rvmark-iframe-guest body {
  padding: 0;
  height: 100%;
  overflow: hidden;
}

html.rvmark-iframe-guest footer {
  display: none;
}

html.rvmark-iframe-guest .tree {
  padding: 0;
}

/* ── rvmark sidepanel guest (embedded in a sidepanel) ── */
html.rvmark-sidepanel-guest,
html.rvmark-sidepanel-guest body {
  padding: 0;
  height: 100%;
  overflow: hidden;
}

html.rvmark-sidepanel-guest #root {
  max-width: none;
  margin: 0;
}

html.rvmark-sidepanel-guest header,
html.rvmark-sidepanel-guest footer {
  display: none;
}

/* ============================================================
   Merged type styles (formerly src/types/*.css).
   ============================================================ */

/* ---- markdown ---- */
/* types/block.css — styles for the block nodetype */

.block-body {
  /* Border aligns with centre of child bullet column (same x as node-children + bullet-w/2) */
  margin-top: 0.4rem;
  margin-left: calc(var(--bullet-w) / 2 - var(--body-border-w) / 2);
  margin-right: calc(var(--bullet-w) / 2 - var(--body-border-w) / 2);
  margin-bottom: 0.5rem;
  border-left: var(--body-border-w) solid var(--border-tree);
  min-width: 0;
  width: 100%;
}

/* On a listbox block, clicking the left border clears the option selection (see
   wireListboxOptions in types/block.ts) — the block's analogue of clicking a
   text node's bullet.
   The painted border is only --body-border-w, far too thin to aim at, so the
   target is a strip as wide as a bullet, centred on that border: the same hit
   box a bullet offers, so the two gestures feel alike. It paints nothing and
   only exists to be clicked, so its whole geometry lives here — the element is
   appended by markdown.ts but never measured there, which keeps it correct for
   any --bullet-w a theme sets, in whatever unit. */
.node-content--listbox .block-body {
  position: relative;
}

.block-body-reset {
  position: absolute;
  top: 0;
  bottom: 0;
  /* `left` is measured from the padding box, so the border width comes back out
     to centre the strip on the border itself. */
  left: calc(-1 * var(--body-border-w) - var(--bullet-w) / 2);
  width: var(--bullet-w);
  /* Pointer comes from .bullet-clickable (see the bullet-column rules), which
     wireBulletActions sets only when there is a listbox to clear. */
}

.block-body-scroll {
  max-height: 50vh;
  overflow-y: auto;
  outline: none;
  padding: 0.4rem 0.6rem 0.4rem 0.6rem;
  line-height: 1.75;
  min-width: 0;
  overflow-x: auto;
}

/* ── Scroll fade masks ── */
.block-body[data-overflow]:not([data-scrolled-to-top]):not([data-scrolled-to-bottom]) .block-body-scroll {
  mask-image: linear-gradient(to bottom, transparent 0%, black var(--md-fade-h), black calc(100% - var(--md-fade-h)), transparent 100%);
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black var(--md-fade-h), black calc(100% - var(--md-fade-h)), transparent 100%);
}

.block-body[data-overflow][data-scrolled-to-top]:not([data-scrolled-to-bottom]) .block-body-scroll {
  mask-image: linear-gradient(to bottom, black calc(100% - var(--md-fade-h)), transparent 100%);
  -webkit-mask-image: linear-gradient(to bottom, black calc(100% - var(--md-fade-h)), transparent 100%);
}

.block-body[data-overflow][data-scrolled-to-bottom]:not([data-scrolled-to-top]) .block-body-scroll {
  mask-image: linear-gradient(to bottom, transparent 0%, black var(--md-fade-h), black 100%);
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black var(--md-fade-h), black 100%);
}

/* ── Block elements ── */
.block-body p {
  margin: 0.3rem 0;
}

.block-body h1,
.block-body h2,
.block-body h3,
.block-body h4,
.block-body h5,
.block-body h6 {
  margin: 0.6rem 0 0.2rem;
  font-weight: 600;
}

.block-body h2 {
  font-size: var(--text-h2);
}

.block-body h3 {
  font-size: var(--text-h3);
}

.block-body h4 {
  font-size: var(--text-h4);
}

.block-body h5 {
  font-size: var(--text-h5);
}

.block-body h6 {
  font-size: var(--text-h6);
  color: var(--muted);
}

.block-body ul,
.block-body ol {
  padding-left: 1.2rem;
  margin: 0.3rem 0;
}

.block-body blockquote {
  border-left: 2px solid var(--border);
  padding-left: 0.6rem;
  color: var(--muted);
  margin: 0.3rem 0;
}

.block-body pre {
  background: var(--surface);
  padding: 0.5rem;
  border-radius: 3px;
  overflow-x: auto;
  margin: 0.4rem 0;
  line-height: 1.45;
}

.block-body pre code {
  padding: 0;
  background: inherit;
}

.block-body code {
  background: var(--surface);
  padding: 0 3px;
  border-radius: 2px;
  font-family: var(--mono, monospace);
  font-size: 0.85em;
}

.block-body a {
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* ── Tables ── */
.block-body table {
  border-collapse: collapse;
  margin: 0.4rem 0;
  font-size: 0.92em;
  width: 100%;
}

.block-body th,
.block-body td {
  border: 1px solid var(--border);
  padding: 0.2rem 0.5rem;
  text-align: left;
}

.block-body thead th {
  background: var(--surface-2);
  font-weight: 500;
}

.block-body tbody tr:nth-child(even) {
  background: var(--bg);
}

/* code inside td/th: don't shrink further inside already-smaller table text */
.block-body td code,
.block-body th code {
  font-size: 1em;
}

/* ── Math blocks ── */
.md-math-block {
  text-align: center;
  margin: 0.6rem 0;
  overflow-x: auto;
}

/* ---- video ---- */
/* types/video.css — styles for the video nodetype */

.node-content--video {
  display: block;
  width: calc(var(--node-w) - var(--bullet-w) - var(--tree-gap));
  padding: 0;
}

.node-content--video:focus {
  box-shadow: -1px 0 0 0 var(--accent);
}

.video-wrap {
  position: relative;
  width: 100%;
  padding-top: 56.25%;
  /* 16:9 */
  margin: 0;
  background: #000;
}

.video-wrap iframe,
.video-wrap video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: none;
  outline: none;
}

.video-wrap--portrait {
  padding-top: 0;
  aspect-ratio: 9 / 16;
  max-width: 20rem;
}

/* Letterbox bars on non-16:9 files render pitch black, not the page background. */
.video-wrap video {
  background: #000;
  object-fit: contain;
}

/* ---- html ---- */
/* types/html.css — styles for the html nodetype */

.html-body {
  max-width: calc(100% - var(--indent));
  box-sizing: border-box;
  margin-left: var(--indent);
  padding: 0.2rem 1rem 0.3rem calc(var(--bullet-w) + 0.3rem);
  border-left: 2px solid var(--border);
  margin-bottom: 0.5rem;
  font-size: var(--text-chrome);
  line-height: 1.6;
}

.html-body:focus {
  outline: none;
}

.html-body-iframe {
  display: block;
  border: none;
  min-height: 8rem;
}

.html-body.html-body-iframe {
  padding: 0;
  margin: 0;
  max-width: 100%;
  width: 100%;
}

.node-content--iframe {
  padding: 0;
}

.node-content--iframe:focus {
  box-shadow: -1px 0 0 0 var(--accent);
}

.node-content--iframe-active,
.node-content--iframe-active:focus,
.node-content--iframe-focused {
  box-shadow: -1px 0 0 0 var(--muted);
  outline: none;
}

/* ---- image ---- */
/* types/image.css — styles for the image nodetype */

.img-body {
  margin: 0;
  padding: 0;
  display: block;
  width: 100%;
}

.img-body img {
  display: block;
  max-width: 100%;
  height: auto;
  border-radius: 2px;
}

/* dark-mode: invert — inverts the image in dark mode (useful for monochrome SVGs with black paths) */
@media (prefers-color-scheme: dark) {
  .img-body--dark-invert img {
    filter: invert(1);
  }
}

/* dark-mode: background — adds a white background in dark mode (useful for images that assume a light canvas) */
@media (prefers-color-scheme: dark) {
  .img-body--dark-background img {
    background: #fff;
    border-radius: 2px;
  }
}

/* align: left */
.img-body--left {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  text-align: left;
}

/* align: center — the default */
.img-body--center {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

/* align: right */
.img-body--right {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  text-align: right;
}

/* ---- sidepanel ---- */
/* ── Sidepanel ── */

.sidepanel {
  display: none;
  overflow: hidden;
  background: var(--bg);
  flex-direction: column;
  position: relative;
}

/* The divider is the drag handle, so it is a pointer target ~9px thick while
 * the rule it draws stays 1px: the background is painted into a centred 1px
 * content box and the rest of the width is transparent padding. Widening the
 * visible rule to match the target would put a heavy bar down the middle of
 * the page; widening only the target costs nothing visually. */
#sidepanel-divider {
  display: none;
  box-sizing: border-box;
  background-clip: content-box;
  touch-action: none;
}

/* An ordinary affordance, so hover deepens the rule toward --muted rather than
 * taking --accent, which marks focus elsewhere in this file. */
#sidepanel-divider:hover {
  background-color: var(--muted);
}

/* Square box, and the × inside it is drawn rather than typed — see the comment
 * in sidepanel.ts. A text × is centred on its typographic metrics, not its ink,
 * so it sits high in the box by a font-dependent amount that no box centring
 * can correct. Two lines across a square viewBox have no such offset. */
.sidepanel-close {
  position: absolute;
  top: 0.4rem;
  right: 0.4rem;
  z-index: 1;
  width: 1.6rem;
  height: 1.6rem;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 3px;
  color: var(--muted);
  padding: 0;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s;
}

/* Sized in the button's own box rather than in em, so the mark cannot drift
 * with an inherited font-size. `display: block` keeps the svg off a text
 * baseline, which would reintroduce the descender gap the glyph had. */
.sidepanel-close svg {
  display: block;
  width: 0.75rem;
  height: 0.75rem;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  fill: none;
}

.sidepanel:hover .sidepanel-close {
  opacity: 1;
}

@media (hover: none) {
  .sidepanel-close {
    opacity: 1;
  }
}

.sidepanel-close:hover {
  color: var(--accent);
  border-color: var(--accent);
}

.sidepanel-close:focus {
  opacity: 1;
  outline: 1px solid var(--accent);
}

/* The iframe fills all available space. `display: block` is load-bearing: an
 * iframe is inline-replaced by default, so it sits on a text baseline and the
 * line box reserves descender space under it — a few pixels of overflow below
 * the panel that no amount of height: 100% removes. */
.sidepanel-iframe {
  border: none;
  display: block;
  width: 100%;
  flex: 1;
}

.sidepanel-error,
.sidepanel-hint {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem;
  color: var(--muted);
  font-size: var(--text-chrome);
  text-align: center;
}

.sidepanel-error {
  white-space: pre-wrap;
  word-break: break-word;
}

/* The two sentences are separate elements so the keyboard-only one can be
 * hidden (below). They should still read as one block: stacked with no gap, so
 * only the inherited line-height separates them — exactly as the single \n did
 * under white-space: pre-line.
 * (The shared rule above supplies the flex centering that positions the hint.) */
.sidepanel-hint {
  flex-direction: column;
}

/* The Escape instruction only applies where there is a keyboard. The state it
 * accompanies ("No sidepanel active.") is shown regardless — on touch the ×
 * button is affordance enough, but the reader still needs to know the panel is
 * empty by design rather than broken. */
@media (hover: none) and (pointer: coarse) {
  .sidepanel-hint-key {
    display: none;
  }
}

/* ── Layout: sidepanel open ── */
/*
 * body.sidepanel-open: two-column grid — tree on left, sidepanel on right.
 */

/* The columns here are the split's starting point only; once the reader drags,
 * sidepanel-split.ts writes grid-template-columns inline and that wins. The
 * gutter track is the divider's own width, and the column-gap the layout used
 * to need is folded into it — the handle now supplies the separation. */
body.sidepanel-open #root {
  max-width: none;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 9px minmax(0, 1fr);
  grid-template-rows: auto minmax(0, 1fr) auto;
  column-gap: 0.5em;
}

body.sidepanel-open header {
  grid-column: 1 / -1;
  grid-row: 1;
}

body.sidepanel-open footer {
  grid-column: 1 / -1;
  grid-row: 3;
}

body.sidepanel-open #sidepanel-divider {
  display: block;
  grid-column: 2;
  grid-row: 2;
  background-color: var(--border);
  padding: 0 4px;
  cursor: col-resize;
}

body.sidepanel-open #tree-scroll {
  grid-column: 1;
  grid-row: 2;
}

body.sidepanel-open .sidepanel {
  display: flex;
  grid-column: 3;
  grid-row: 2;
}

/* ── Portrait mobile: stack vertically ── */
@media (max-width: 768px) and (orientation: portrait) {
  body.sidepanel-open #root {
    grid-template-columns: 1fr;
    grid-template-rows: auto minmax(0, 1fr) 9px minmax(0, 1fr) auto;
    column-gap: 0;
    row-gap: 0.5em;
  }

  body.sidepanel-open header {
    display: none !important;
  }

  body.sidepanel-open .sidepanel {
    grid-column: 1;
    grid-row: 2;
  }

  body.sidepanel-open #sidepanel-divider {
    grid-column: 1;
    grid-row: 3;
    padding: 4px 0;
    cursor: row-resize;
  }

  body.sidepanel-open #tree-scroll {
    grid-column: 1;
    grid-row: 4;
  }

  body.sidepanel-open footer {
    grid-row: 5;
  }
}

/* ---- tr ---- */
/* ── tr row <li>: dissolve into the parent grid ── */
.tr-row {
  display: contents;
}

/* ── Toggle bullet: column 1 grid item ── */
.tr-row>.tr-toggle {
  grid-column: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  justify-self: start;
  width: var(--bullet-w);
}

/* ── .node-content of a tr row: subgrid row spanning columns 2+ ── */
.tr-row>.node-content {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 2 / -1;
  align-items: stretch;
  border-left: 1px solid var(--border, #ddd);
  border-right: 1px solid var(--border, #ddd);
  border-bottom: 1px solid var(--border, #ddd);
  padding: 0;
  width: auto;
}

/* Restore top border after an expansion panel */
.tr-row:has(> .node-content[aria-expanded="true"])+.tr-row>.node-content {
  border-top: 1px solid var(--border, #ddd);
}

/* ── Individual cells ── */
.tr-cell {
  padding: 0.2em 0.5em;
  font-size: 0.9em;
  border-right: 1px solid var(--border, #ddd);
  display: block;
  align-content: center;
}

.tr-cell:last-of-type {
  border-right: none;
}

/* ── Expansion panel ── */
.tr-row>.node-children {
  grid-column: 2 / -1;
  display: none;
  --children-indent: 0;
}

.tr-row:has(> .node-content[aria-expanded="true"])>.node-children {
  display: block;
}

.tr-row>.node-children>.tree {
  padding: 0;
}

/* Toggle arrow: driven by aria-expanded on sibling .node-content */
/* .tr-toggle also carries .toggle, so the leaf dot and the box metrics are
   already inherited from .toggle::before — this rule only swaps the dot for the
   expand triangle. */
.tr-row:has(> .static-toggle)>.tr-toggle:not(.leaf)::before,
.tr-row:has(> .node-content[aria-expanded])>.tr-toggle:not(.leaf)::before {
  -webkit-mask-image: var(--icon-toggle);
  mask-image: var(--icon-toggle);
}

.tr-row:has(> .node-content[aria-expanded="true"])>.tr-toggle:not(.leaf)::before {
  transform: rotate(90deg);
}

/* Recolor toggle on focus */
.tr-row:has(> .node-content:focus)>.tr-toggle {
  color: var(--accent);
}

/* No border-radius on selection/focus for tr rows */
.tr-row>.node-content[aria-selected="true"],
.tr-row>.node-content:focus {
  border-radius: 0;
}

/* code inside cells: match .node-label code styling */
.tr-cell code {
  background: var(--surface);
  padding: 0 3px;
  border-radius: 2px;
  font-family: var(--mono, monospace);
  font-size: 0.85em;
}

/* ---- table ---- */
/* ── table-node li: the grid container ── */
.table-node {
  display: grid;
  grid-template-columns: calc(var(--bullet-w) + var(--tree-gap)) var(--table-cols, repeat(1, 1fr));
  width: var(--node-w);
}

/* ── Toggle bullet: column 1, row 1 ── */
.table-node>.table-toggle {
  grid-column: 1;
  grid-row: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--bullet-w);
  justify-self: start;
}

/* ── Header row: columns 2+, row 1 ── */
.node-content--table {
  display: grid;
  grid-template-columns: subgrid;
  grid-column: 2 / -1;
  grid-row: 1;
  align-items: stretch;
  border: 1px solid var(--border, #ddd);
  padding: 0;
  width: auto;
}

.node-content--table .tr-cell {
  font-weight: 500;
}

/* ── Children container: dissolve into the parent grid ── */
.node>.node-content--table[aria-expanded="true"]~.node-children {
  display: contents;
  --children-indent: 0;
}

.table-node>.node-children>.tree {
  display: contents;
}

/* First tr row top border, and tr rows following a non-tr node */
.table-node>.node-children>.tree>.tr-row:first-child>.node-content,
.table-node>.node-children>.tree>.node:not(.tr-row)+.tr-row>.node-content {
  border-top: 1px solid var(--border, #ddd);
}

/* Non-tr children: span full width including bullet zone */
.table-node>.node-children>.tree>.node:not(.tr-row) {
  grid-column: 1 / -1;
}

/* Toggle: + / − driven by aria-expanded on the sibling .node-content--table.
   .table-toggle also carries .toggle, so the box and metrics come from
   .toggle::before — these rules only swap the mask image, which keeps every
   marker in the UI on identical geometry. Both states are icons: mixing a text
   '+' with a masked '−' gave the two states different weights and baselines. */
.table-node>.table-toggle:not(.leaf)::before {
  -webkit-mask-image: var(--icon-expand);
  mask-image: var(--icon-expand);
}

.table-node:has(> .node-content--table[aria-expanded="true"])>.table-toggle:not(.leaf)::before {
  -webkit-mask-image: var(--icon-collapse);
  mask-image: var(--icon-collapse);
}

/* Recolor toggle on focus */
.table-node:has(> .node-content--table:focus)>.table-toggle {
  color: var(--accent);
}

/* Selection / focus: no border-radius */
.table-node>.node-content--table[aria-selected="true"],
.table-node>.node-content--table:focus {
  border-radius: 0;
}

/* ── Static fallback: table/tr disclosure ──
   The table family renders the same grid in both modes, so the fallback needs
   a collapse that adds no box of its own. <details> cannot do it: everything
   after its <summary> goes into an anonymous slot box that display:contents
   does not dissolve, and that box — not .node-children — becomes the grid
   item, so `grid-column: 2 / -1` is ignored and every cell alignment breaks.

   A checkbox has no such box. The input is display:none and its <label> IS
   the toggle element the grid already places, so the box tree is exactly the
   hydrated one and :checked can drive the same rules aria-expanded drives. */
.static-toggle {
  display: none;
}

/* Table: children are display:contents so the tr rows join the table's grid —
   the static twin of the [aria-expanded="true"] rule above. */
.static-toggle:checked~.node-children {
  display: contents;
  --children-indent: 0;
}

/* tr: the expansion panel is a plain grid item in columns 2+. */
.tr-row>.static-toggle:checked~.node-children {
  display: block;
}

/* `open: always` — shown outright, with no checkbox to key off. The two rules
   above are what a checked toggle buys; these are the same result reached
   without one, since an always-open node offers no collapse to drive. */
.table-node.static-always-open>.node-children {
  display: contents;
  --children-indent: 0;
}

.tr-row.static-always-open>.node-children {
  display: block;
}

/* Toggle glyphs, mirroring the aria-expanded rules for each family. */
.table-node>.static-toggle:checked~.table-toggle:not(.leaf)::before {
  -webkit-mask-image: var(--icon-collapse);
  mask-image: var(--icon-collapse);
}

.tr-row>.static-toggle:checked~.tr-toggle:not(.leaf)::before {
  transform: rotate(90deg);
}

/* Row borders keyed off the open state, matching the :has([aria-expanded])
   rules above — a tr following an open expansion panel regains its top border. */
.tr-row:has(> .static-toggle:checked)+.tr-row>.node-content {
  border-top: 1px solid var(--border, #ddd);
}

/* The label is the click target, so it says so. */
label.table-toggle:not(.leaf),
label.tr-toggle:not(.leaf) {
  cursor: pointer;
}

label.table-toggle:not(.leaf):hover,
label.tr-toggle:not(.leaf):hover {
  color: var(--accent);
}

/* ============================================================
   Print
   ============================================================ */
/* Prints the tree exactly as currently expanded on screen — collapsed
   subtrees stay collapsed (CSS can't force JS-driven aria-expanded state,
   and force-expanding is unsafe anyway given transclusion cycles). The
   screen layout is a fixed-height scroll viewport (100dvh, overflow clipped,
   horizontal scroll on overflow); none of that means anything on paper, so
   it's unwound here to plain flowing, wrapping block layout that paginates. */
@media print {
  @page {
    margin: 1.2cm;
  }

  html,
  body {
    height: auto;
    overflow: visible;
    padding: 0;
    background: #fff;
    color: #000;
  }

  #root {
    max-width: none;
    height: auto;
    overflow: visible;
  }

  header,
  footer,
  .sidepanel,
  #sidepanel-divider,
  .show-hidden-toggle {
    display: none !important;
  }

  #tree-scroll,
  #static-content {
    overflow: visible;
    container-type: normal;
    --node-w: 100%;
  }

  /* No visibility override here: whichever of #tree-scroll / #static-content
     is already showing (hydrated tree in the normal case, static fallback
     only if JS never ran) is what prints — same "print what's on screen"
     rule as expand/collapse state. */
  .tree {
    overflow: visible;
  }

  /* --node-w is 100% here, and a table row carries its own indent as a margin:
     auto fills what is left, where 100% would overflow by the indent. */
  .table-node {
    width: auto;
  }

  .node-content {
    width: auto;
    cursor: auto;
  }

  .node-label {
    word-break: break-word;
  }

  /* Collapsed subtrees print as collapsed, matching the on-screen state.
     The fallback's disclosures do this natively — a closed <details> prints
     closed — so only the hydrated form needs asserting here.

     Always-open nodes and the tr/table family are exempt: they have no
     aria-expanded and no <details>, so their open state lives on the checkbox
     or on .static-always-open. Without the exemption they print collapsed. */
  .node:not(:has(> .node-content[aria-expanded="true"])):not(:has(> details)):not(.static-always-open):not(:has(> .static-toggle:checked))>.node-children {
    display: none;
  }

  /* Permalink anchors and hover/focus-only affordances don't apply to a
     static page. */
  .node-id {
    display: none;
  }

  .toggle::after {
    display: none;
  }

  .node-content:focus,
  .node-content[aria-selected="true"] {
    background: none;
    outline: none;
  }

  /* Scroll fade masks and inner scroll clipping are screen-only. */
  .block-body-scroll {
    max-height: none;
    overflow: visible;
    mask-image: none;
    -webkit-mask-image: none;
  }

  /* Video/iframe embeds have no paper equivalent; drop the player chrome
     but leave the row (label/caption text) in place. */
  .video-wrap,
  .html-body-iframe {
    display: none;
  }

  pre,
  .block-body table,
  .img-body img {
    break-inside: avoid;
  }
}