* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: var(--font-ui);
  font-size: var(--font-size-editor);
  color: var(--text-normal);
  background: var(--bg-primary);
  line-height: var(--line-height-relaxed);
}

/* Full-width header, above the sidebar/main grid rather than inside the
   sidebar's 16rem column — a site title and a home link read as belonging to
   the whole page, not to the navigation panel. Absent when the settings give
   canopy neither a title, a logo, nor a home link (see shell.ts), so a site
   with none of those keeps today's chrome-free top edge. */
.canopy-topbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-4);
  border-bottom: 1px solid var(--border);
  font-weight: var(--font-weight-semibold);
}
/* Every other link the shell draws (sidebar, content, outline, backlinks,
   page-nav) sets its own rest/hover colors; the topbar's two links —
   the site title and .canopy-home — were the one place nothing did, so
   both rendered in the browser's own default link blue with a permanent
   underline instead of reading as chrome. .canopy-home's own rule below
   still wins on specificity for color at rest (two classes beat one class
   plus a type), so this only supplies the site-title link's rest color and
   the underline reset shared by both; :hover here (two classes plus this
   pseudo-class) outranks .canopy-home's plain rule, so hover still reaches
   it too. */
.canopy-topbar > a {
  color: var(--text-normal);
  text-decoration: none;
}
.canopy-topbar > a:hover {
  color: var(--accent);
  text-decoration: underline;
}
.canopy-topbar > a:not(.canopy-home) { display: flex; align-items: center; gap: var(--sp-2); }
/* A definite height, not max-height: a brand file supplies whatever it has,
   and a tall logo must not grow the bar past a single line — max-height would
   say that more precisely (cap, don't force), but it makes the logo's inline
   contribution to .canopy-topbar > a's own intrinsic width indefinite, and
   that indefinite replaced-element size is what the title text wraps around
   despite room to spare: measured live, the anchor's computed width came out
   equal to gap + text alone, as if the logo contributed zero, even though it
   paints at its correct capped size right next to that too-narrow box (a
   flex-basis:auto sizing gap most engines have for max-height-constrained
   replaced children, not anything specific to this logo or this text). A
   definite height gives the logo a definite aspect-ratio-derived width up
   front, which the anchor's intrinsic-size pass can add in like any other
   child — the trade is that a logo shorter than 1.75rem now scales up to fill
   it instead of sitting at its own smaller natural size. */
.canopy-logo { height: 1.75rem; max-width: 100%; width: auto; }
/* Specificity beats .canopy-topbar a without !important, which would also
   override a caller's own stylesheet. */
.canopy-topbar .canopy-home { font-weight: 400; font-size: 0.9em; color: var(--text-muted); }
.canopy-home::before { content: "← "; }
/* home.url can name a page outside the site entirely (settings lets it be
   any URL, not just an internal path) — and it sits right after the
   breadcrumb, which never leaves the site, so a reader has every reason to
   expect the same here unless told otherwise. The icon is the telling:
   isExternalUrl already decides this in shell.ts, so nothing here
   re-derives it. currentColor (not a fixed var(--text-muted), unlike this
   file's other mask icons) is deliberate: it's what lets the icon dim/light
   up together with .canopy-home's own hover color change above, with no
   second rule needed to keep the two in sync. */
.canopy-home-external::after {
  content: "";
  display: inline-block;
  width: 0.7em;
  height: 0.7em;
  margin-left: 0.25em;
  vertical-align: -0.05em;
  background-color: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M18%2013v6a2%202%200%200%201-2%202H5a2%202%200%200%201-2-2V8a2%202%200%200%201%202-2h6M15%203h6v6M10%2014L21%203'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M18%2013v6a2%202%200%200%201-2%202H5a2%202%200%200%201-2-2V8a2%202%200%200%201%202-2h6M15%203h6v6M10%2014L21%203'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
}

/* The ancestor trail (renderBreadcrumb): an <ol> laid out as a row, a "/"
   between each pair of entries rather than after every one (:not(:last-child)
   guards that), and no bullet — a breadcrumb reads as a path, not a list.
   No width/overflow handling of its own: a trail with nowhere left to fit
   wraps to its own line the same way everything else in .canopy-topbar
   already does (flex-wrap: wrap, above), rather than this duplicating that
   with a second, competing overflow strategy. font-weight resets
   .canopy-topbar's own semibold (set for the site title) back to normal —
   a trail of equally-bold entries would fight the title for visual weight
   instead of reading as secondary to it. */
.canopy-breadcrumb {
  font-weight: 400;
  font-size: 0.9em;
  color: var(--text-muted);
}
.canopy-breadcrumb ol {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  list-style: none;
  margin: 0;
  padding: 0;
}
.canopy-breadcrumb li:not(:last-child)::after {
  content: "/";
  margin: 0 var(--sp-2);
  color: var(--text-faint);
}
/* The trail's own last crumb (the page a reader is already on) is never a
   link — the same reasoning aria-current="page" already gives the sidebar's
   own current entry, restated as a plain <li> here (see renderBreadcrumb)
   rather than a second attribute a <nav><ol> has no reason to repeat. It
   reads in the normal text color, not muted like the rest of the trail, so
   it stands out as the trail's own endpoint. */
.canopy-breadcrumb li:last-child { color: var(--text-normal); }
.canopy-breadcrumb a { color: inherit; text-decoration: none; }
.canopy-breadcrumb a:hover { color: var(--accent); text-decoration: underline; }

/* .canopy-topbar-controls (shell.ts) — not .canopy-search or
   .canopy-theme-toggle individually — owns the "pushed to the far edge, or
   simply starts the bar when alone" placement: search and the toggle wrap as
   one unit when the topbar runs out of room (see the wrapper's own comment in
   shell.ts for why), so only their shared box needs margin-left: auto. */
.canopy-topbar-controls {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin-left: auto;
}
/* Hidden by default (see shell.ts) until a caller-supplied script reveals it.
   position: relative so ::before below can place the icon inside the input
   regardless of what a caller's own tokens.css does — a caller carrying
   canopy-page's search UI happens to set this too (its own
   .canopy-search-results dropdown needs it), but this shell has no way to
   know that, so it supplies its own rather than depending on a caller's CSS
   for its own icon to position correctly. */
.canopy-search { position: relative; }
/* Same masked-icon technique as .canopy-theme-toggle and the mobile nav
   control above — an inert glyph, not a button, so pointer-events: none
   lets a click straight through to the input underneath it. */
.canopy-search::before {
  content: "";
  position: absolute;
  top: 50%;
  left: var(--sp-2);
  transform: translateY(-50%);
  width: 1rem;
  height: 1rem;
  background-color: var(--text-muted);
  -webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M3%2011a8%208%200%201%200%2016%200%208%208%200%201%200-16%200M21%2021l-4.35-4.35'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M3%2011a8%208%200%201%200%2016%200%208%208%200%201%200-16%200M21%2021l-4.35-4.35'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
  pointer-events: none;
}
.canopy-search input[type="search"] {
  font: inherit;
  padding: var(--sp-2) var(--sp-2) var(--sp-2) calc(1rem + var(--sp-2) * 2);
  border: 1px solid var(--border);
  border-radius: var(--radius-m);
  background: var(--bg-primary);
  color: var(--text-normal);
}

/* Hidden by default (see shell.ts) until a caller-supplied script reveals it
   — same reasoning as .canopy-search. */
.canopy-theme-toggle {
  width: 1.5rem;
  height: 1.5rem;
  padding: 0;
  border: none;
  background-color: var(--text-muted);
  -webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M12%2017a5%205%200%20100-10%205%205%200%20000%2010zM12%201v2M12%2021v2M4.22%204.22l1.42%201.42M18.36%2018.36l1.42%201.42M1%2012h2M21%2012h2M4.22%2019.78l1.42-1.42M18.36%205.64l1.42-1.42'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M12%2017a5%205%200%20100-10%205%205%200%20000%2010zM12%201v2M12%2021v2M4.22%204.22l1.42%201.42M18.36%2018.36l1.42%201.42M1%2012h2M21%2012h2M4.22%2019.78l1.42-1.42M18.36%205.64l1.42-1.42'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
  cursor: pointer;
}
.canopy-theme-toggle:hover { background-color: var(--text-normal); }
/* The icon shown once dark is actually in effect — the same two-path split
   tokens.ts's palette uses below it (system preference, then an explicit
   data-theme override that wins regardless of it), so the icon never
   disagrees with the colors already on screen: a reader sees a moon exactly
   when the page is dark, not only after a click that merely requests it. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .canopy-theme-toggle {
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M21%2012.79A9%209%200%201%201%2011.21%203%207%207%200%200%200%2021%2012.79z'%2F%3E%3C%2Fsvg%3E");
    mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M21%2012.79A9%209%200%201%201%2011.21%203%207%207%200%200%200%2021%2012.79z'%2F%3E%3C%2Fsvg%3E");
  }
}
:root[data-theme="dark"] .canopy-theme-toggle {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M21%2012.79A9%209%200%201%201%2011.21%203%207%207%200%200%200%2021%2012.79z'%2F%3E%3C%2Fsvg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M21%2012.79A9%209%200%201%201%2011.21%203%207%207%200%200%200%2021%2012.79z'%2F%3E%3C%2Fsvg%3E");
}

/* The sidebar tint and its divider paint here as a hard-stopped gradient,
   not as .canopy-sidebar's own background/border. This container spans the
   full row height (min-height below, or .canopy-main's own height on a long
   page) independent of how tall any one cell's own content is, so a gradient
   here reaches the bottom of the column even when the sticky sidebar box
   inside it (below) is short. Painting it on .canopy-main instead — the
   seemingly obvious alternative — doesn't work: .canopy-main centers a
   content-max-width column with its own side margins, so its box doesn't
   reach the actual column boundary on any viewport wider than that max-width
   (measured live at 1280px: main's centered box left a visible gap between
   the sidebar and where main's own border would sit). The gradient is
   painted on this container's true coordinate space instead, so the stop
   always lands exactly on the grid's real column boundary regardless of
   what either child does with its own width — read from --canopy-sidebar-w
   rather than repeating the grid column's own 16rem as a second literal, so
   a future change to the sidebar's width cannot move one without the other.
   Reset on the mobile breakpoint below, where the grid drops to one column
   and there is no boundary left to mark this way.
   --canopy-sidebar-w is declared here, on the rule, not on :root alongside
   tokens.ts's --sp-* / --bg-* vocabulary: it exists only to keep the two
   declarations below in sync with each other, not as a value a caller is
   meant to read or override, so it isn't part of the design-token contract
   this file's own header comment already says the sidebar's width is not
   ("Sidebar width is a local layout constant, not a design token"). */
.canopy-layout {
  --canopy-sidebar-w: 16rem;
  display: grid;
  grid-template-columns: var(--canopy-sidebar-w) 1fr;
  min-height: 100vh;
  background: linear-gradient(
    to right,
    var(--bg-secondary) 0, var(--bg-secondary) var(--canopy-sidebar-w),
    var(--border) var(--canopy-sidebar-w), var(--border) calc(var(--canopy-sidebar-w) + 1px),
    var(--bg-primary) calc(var(--canopy-sidebar-w) + 1px), var(--bg-primary) 100%
  );
}

/* A grid item stretches to the tallest sibling by default, so without
   align-self this box would grow exactly as tall as .canopy-main and its own
   overflow-y would never engage — the whole page would scroll as one unit and
   the sidebar would disappear upward with it. align-self opts out of that
   stretch so max-height: 100vh is the sidebar's own box, and position: sticky
   keeps that box pinned at the viewport top while .canopy-main scrolls past
   it. The mobile breakpoint below releases all three: a single-column layout
   has no "beside" for the sidebar to stay pinned against.
   100vh here is the sidebar's full height in isolation; with .canopy-topbar
   as a sibling above .canopy-layout rather than inside it, the topbar's own
   height already comes out of the viewport before .canopy-layout starts, so
   this doesn't need to account for it. It does need to give up a fixed
   height, though: with .canopy-topbar in normal flow above .canopy-layout,
   a flat 100vh would run the sidebar's box exactly the topbar's height past
   the bottom of the first viewport. max-height caps it there instead, so a
   short nav sizes to its own content and only a nav list that actually fills
   the screen engages overflow-y — at the cost of the box no longer reaching
   the bottom of a short column on its own, which is why the tint and the
   divider (above, on .canopy-layout) don't live here anymore. */
.canopy-sidebar {
  padding: var(--sp-6) var(--sp-4);
  align-self: start;
  position: sticky;
  top: 0;
  max-height: 100vh;
  overflow-y: auto;
}

.canopy-sidebar ul { list-style: none; margin: 0; padding-left: var(--sp-3); }
.canopy-nav > nav > ul { padding-left: 0; }
.canopy-sidebar a { color: var(--text-normal); text-decoration: none; }
.canopy-sidebar a:hover { color: var(--accent); text-decoration: underline; }
.canopy-sidebar span { color: var(--text-muted); }
/* Minimal default hierarchy: only the top level is distinguished, matching the
   minimal-configuration principle already applied to Wave 1 (no predefined
   multi-level color themes) — a consumer who wants more can target
   .canopy-nav-l{n} directly, now that depth is exposed in the markup. A
   top-level folder's link sits inside <details><summary> rather than as
   .canopy-nav-l0's own direct child (see .canopy-nav-group below), so both
   shapes need naming here — a leaf's link is a direct child, a folder's is
   one summary deeper. */
.canopy-nav-l0 > a,
.canopy-nav-l0 > span,
.canopy-nav-l0 > details > summary > a,
.canopy-nav-l0 > details > summary > span {
  font-weight: var(--font-weight-semibold);
}

/* A sidebar group with children — see renderNavList's own doc comment for
   why this is a separate class from .canopy-nav (the outer mobile-overlay
   disclosure) rather than reusing it. display: flex on <summary> is what
   suppresses the native disclosure triangle (a browser only draws one on a
   summary still in its default display: list-item), so list-style: none is
   belt-and-suspenders for the browsers that draw one anyway — the same pair
   .canopy-nav's own mobile control below already uses. */
.canopy-nav-group > summary {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  list-style: none;
  cursor: pointer;
}
.canopy-nav-group > summary::-webkit-details-marker { display: none; }
/* The chevron rotates 90deg open rather than swapping to a second icon —
   one glyph, not a pair, since "expanded" is a rotation of "collapsed" and
   not a different shape the way the mobile menu/x control's two icons are. */
.canopy-nav-group > summary::before {
  content: "";
  flex: none;
  width: 0.75rem;
  height: 0.75rem;
  background-color: var(--text-muted);
  -webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M9%2018l6-6-6-6'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M9%2018l6-6-6-6'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
  transition: transform 0.15s ease;
}
.canopy-nav-group[open] > summary::before { transform: rotate(90deg); }
/* The link/label inside <summary> takes the rest of the row — without this,
   a flex summary sizes each child to its own content and the link's own
   click target ends at its text, not the full row width a sibling leaf's
   <li><a> already gives a reader. */
.canopy-nav-group > summary > a,
.canopy-nav-group > summary > span {
  flex: 1;
  min-width: 0;
}

/* The page a reader is already on. A tinted pill rather than a full-row bar:
   padding+background on the <a> itself needs no coordination with the
   nested <ul>'s own padding-left (a row-spanning treatment would), so it
   works at every nav depth unchanged. The negative margin cancels the
   added horizontal padding so the label's left edge still lines up with
   every sibling item that has no background. --sidebar-active-bg reuses
   --accent's own color (0.22 dark / 0.16 light — see tokens.ts for why this
   runs higher than the callout backgrounds' own opacity) rather than
   introducing a new hue. */
.canopy-sidebar a[aria-current="page"] {
  display: block;
  color: var(--accent);
  font-weight: var(--font-weight-semibold);
  background: var(--sidebar-active-bg);
  border-radius: var(--radius-m);
  padding: 1px var(--sp-2);
  margin: 0 calc(var(--sp-2) * -1);
}

/* A disclosure that ships open: the desktop layout is unchanged and needs no
   override, while a narrow screen can collapse the list entirely.
   [open] is load-bearing, not decoration: a reader can close the disclosure on a
   narrow screen, then cross this breakpoint (e.g. rotating a phone to landscape)
   with it still closed. Hiding the summary unconditionally would strand them with
   no control to reopen it — hiding it only while open keeps a closed disclosure's
   control visible at every width. */
.canopy-nav[open] > summary { display: none; }

/* min-width: 0 overrides a grid item's default min-width: auto, which would
   otherwise use max-width as a floor on .canopy-layout's column track — on the
   single-column mobile layout that kept the whole page (not just this box)
   768px wide regardless of viewport, independent of what the page's own
   content was. */
.canopy-main {
  padding: var(--sp-8) var(--sp-6);
  max-width: var(--content-max-width);
  min-width: 0;
  margin: 0 auto;
  width: 100%;
}

/* Base prose rhythm. Every value reads from the same spacing/type tokens the
   rest of the shell already uses (--sp-*, --font-weight-semibold) rather than
   introducing a second, article-only scale — a page's own content is meant to
   look like it belongs to the same design as its chrome, not a pasted-in
   typography plugin.
   The page's own <h1> renders inside .canopy-content too (shell.ts puts the
   document's rendered HTML straight into it, title and all), so a
   heading-only top margin would still leave a gap above the very first thing
   on the page — :first-child/:last-child trims whatever that first and last
   element actually are, the same rule Tailwind Typography's "prose" class and
   GitHub's own markdown-body stylesheet both use for the same reason. */
.canopy-content > :first-child { margin-top: 0; }
.canopy-content > :last-child { margin-bottom: 0; }

.canopy-content h1, .canopy-content h2, .canopy-content h3,
.canopy-content h4, .canopy-content h5, .canopy-content h6 {
  margin: var(--sp-8) 0 var(--sp-4);
  font-weight: var(--font-weight-semibold);
  line-height: 1.25;
}
.canopy-content h1 { font-size: 1.875em; }
.canopy-content h2 { font-size: 1.5em; }
.canopy-content h3 { font-size: 1.25em; }
.canopy-content h4 { font-size: 1.125em; }
.canopy-content h5, .canopy-content h6 { font-size: 1em; }

.canopy-content p, .canopy-content ul, .canopy-content ol, .canopy-content table {
  margin: var(--sp-4) 0;
}
.canopy-content ul, .canopy-content ol { padding-left: var(--sp-6); }
.canopy-content li + li { margin-top: var(--sp-2); }

/* A plain `>` quote, not a `[!type]` callout (the .callout class below wins
   on specificity over this element selector, so a callout's own border/tint
   is unaffected). Muted rather than accented: an unmarked quote is quieter
   than a callout by design, the same distinction a callout's color already
   exists to make against it. */
.canopy-content blockquote {
  margin: var(--sp-4) 0;
  padding: 0 var(--sp-4);
  border-left: 3px solid var(--border-strong);
  color: var(--text-muted);
}
.canopy-content blockquote > :first-child { margin-top: 0; }
.canopy-content blockquote > :last-child { margin-bottom: 0; }

.canopy-content hr {
  margin: var(--sp-8) 0;
  border: none;
  border-top: 1px solid var(--border);
}

.canopy-content a { color: var(--accent); }
.canopy-content a:hover { color: var(--accent-hover); }
.canopy-content img { max-width: 100%; height: auto; }
/* overflow-x: auto alone leaves a wide block's cut-off right edge looking
   like the code just stopped there, on any OS/browser that hides its
   scrollbar until hovered. The four layers below are the standard no-script
   "scroll shadow" affordance for that (the technique widely credited to Lea
   Verou): a shadow pair, always painted at this box's own visible edges
   (background-attachment: scroll — the default, named explicitly here so
   the contrast with the cover pair below reads as intentional, not an
   omission) and a cover pair the same width as the background, positioned
   at the *content's* own two ends (background-attachment: local, so it
   scrolls together with the content rather than staying put in the
   viewport). At rest, a cover sits exactly on top of the shadow at
   whichever end there's nothing left to scroll to — content start and box
   start coincide there, so the opaque cover fully hides that edge's shadow.
   Scrolling moves the local-attached cover out from under the
   scroll-attached shadow at the end being scrolled away from, revealing it,
   while the far cover (approaching its own matching end) hides that
   shadow again once fully scrolled. A block that never needs to scroll
   keeps both shadows covered at all times.
   Layer order matters: covers are listed first, so they paint on top of
   the shadows. The cover color matches Shiki's own github-light background
   (its inline background-color, #fff) so a covered shadow reads as simply
   absent rather than as a mismatched patch; the dark-mode pair below
   substitutes --shiki-dark-bg, the same custom property (set inline by
   Shiki on this same element) the .shiki dark override already reads for
   background-color itself. Verified empirically (an isolated fixture,
   scrolled programmatically to each end) rather than assumed from the
   recipe alone — an earlier, simpler two-layer version of this rule
   (fade-to-background-color only, no separate shadow, no explicit
   background-size) looked plausible but rendered with no visible effect at
   all once actually checked in a browser: a same-color fade painted over a
   background already that color composites to no visible edge, of course,
   in hindsight, but that wasn't obvious captured only in the CSS itself. */
.canopy-content pre {
  margin: var(--sp-4) 0;
  padding: var(--sp-4);
  border-radius: var(--radius-m);
  overflow-x: auto;
  background-image:
    linear-gradient(to right, #fff 60%, transparent),
    linear-gradient(to left, #fff 60%, transparent),
    linear-gradient(to right, rgba(0, 0, 0, 0.15), transparent),
    linear-gradient(to left, rgba(0, 0, 0, 0.15), transparent);
  background-repeat: no-repeat;
  background-size: var(--sp-6) 100%, var(--sp-6) 100%, var(--sp-2) 100%, var(--sp-2) 100%;
  background-position: 0 0, 100% 0, 0 0, 100% 0;
  background-attachment: local, local, scroll, scroll;
}
.canopy-content code { font-family: var(--font-monospace); }
.canopy-content table { border-collapse: collapse; }
.canopy-content th, .canopy-content td {
  border: 1px solid var(--border);
  padding: var(--sp-2) var(--sp-3);
}

/* Callouts (`> [!type]`): tinted blockquotes with an icon + title line.
   Color comes from the callout tokens; the icon is a masked SVG so it
   follows the type color with no inline markup in the page. */
.canopy-content .callout {
  margin: var(--sp-4) 0;
  padding: var(--sp-3) var(--sp-4);
  border-left: 3px solid var(--callout-color);
  border-radius: var(--radius-m);
  background: var(--callout-bg);
}
.canopy-content .callout-note { --callout-color: var(--callout-note); --callout-bg: var(--callout-note-bg); --callout-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M17%203a2.85%202.85%200%201%201%204%204L7.5%2020.5%202%2022l1.5-5.5Z'%2F%3E%3C%2Fsvg%3E"); }
.canopy-content .callout-tip { --callout-color: var(--callout-tip); --callout-bg: var(--callout-tip-bg); --callout-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M9%2018h6M10%2022h4M15.09%2014c.18-.98.65-1.74%201.41-2.5A4.65%204.65%200%200%200%2018%208%206%206%200%200%200%206%208c0%201%20.23%202.23%201.5%203.5A4.61%204.61%200%200%201%208.91%2014'%2F%3E%3C%2Fsvg%3E"); }
.canopy-content .callout-warning { --callout-color: var(--callout-warning); --callout-bg: var(--callout-warning-bg); --callout-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'm21.73%2018-8-14a2%202%200%200%200-3.46%200l-8%2014A2%202%200%200%200%204%2021h16a2%202%200%200%200%201.73-3M12%209v4M12%2017h.01'%2F%3E%3C%2Fsvg%3E"); }
.canopy-content .callout-danger { --callout-color: var(--callout-danger); --callout-bg: var(--callout-danger-bg); --callout-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M7.86%202h8.28L22%207.86v8.28L16.14%2022H7.86L2%2016.14V7.86L7.86%202zM12%208v4M12%2016h.01'%2F%3E%3C%2Fsvg%3E"); }
.canopy-content .callout-quote { --callout-color: var(--callout-quote); --callout-bg: var(--callout-quote-bg); --callout-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M10%2011H6a2%202%200%200%201-2-2V7a2%202%200%200%201%202-2h2a2%202%200%200%201%202%202v6a4%204%200%200%201-4%204M20%2011h-4a2%202%200%200%201-2-2V7a2%202%200%200%201%202-2h2a2%202%200%200%201%202%202v6a4%204%200%200%201-4%204'%2F%3E%3C%2Fsvg%3E"); }
.canopy-content .callout-title {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin: 0 0 var(--sp-2);
  font-weight: var(--font-weight-semibold);
  color: var(--callout-color);
}
.canopy-content .callout-title::before {
  content: "";
  width: 1.1em;
  height: 1.1em;
  flex: none;
  background-color: var(--callout-color);
  -webkit-mask: var(--callout-icon) center / contain no-repeat;
  mask: var(--callout-icon) center / contain no-repeat;
}
.canopy-content .callout > :last-child { margin-bottom: 0; }
.canopy-content .callout > p { margin: var(--sp-2) 0; }

/* On-this-page outline. Beside the text on a wide screen, after it when there is
   no room — a plain anchor list, no script, like the rest of the shell. Placed
   after .canopy-content in shell.ts's own markup (not before it) so a reader
   without room for the wide layout below reaches the article before the list
   of its own headings — the DOM order this box inherits when unpositioned is
   also what assistive tech reads, so the fix has to live in markup order, not
   just in how this box is later repositioned onto the grid. margin-top gives
   it room under the article it now follows; the grid below resets margin to 0
   once this box is pulled beside the text instead of following it in flow. */
.canopy-outline {
  margin: var(--sp-8) 0 var(--sp-6);
  padding: var(--sp-3) var(--sp-4);
  border-left: 2px solid var(--border);
  font-size: 0.9em;
}
.canopy-outline h2 { font-size: 1em; color: var(--text-muted); }
.canopy-outline ul { list-style: none; margin: 0; padding: 0; }
.canopy-outline li { margin: var(--sp-2) 0; }
.canopy-outline a { color: var(--text-muted); text-decoration: none; }
.canopy-outline a:hover { color: var(--accent); text-decoration: underline; }
.canopy-outline-l1 { padding-left: var(--sp-4); }
.canopy-outline-l2 { padding-left: var(--sp-6); }

@media (min-width: 75rem) {
  /* Room for a column beside the text. .canopy-content and .canopy-backlinks
     stay in one column; .canopy-outline gets a second, sized independently of
     .canopy-content's own max-width so a long line of prose can't push it
     around. Placed on an explicit grid (rather than the position: absolute
     this used before position: sticky replaced it below) because sticky's
     inset properties offset from the box's own in-flow position, not from a
     containing block's edge the way absolute's do — an explicit grid area is
     what keeps the outline "beside" the text once it also needs to stay in
     flow to be sticky at all.
     :has() scopes the wider column to pages that actually have an outline
     (isOutlineUseful in shell.ts) — without it, a page short enough to skip
     the outline would still carry the extra width as a permanent gap where
     an outline never renders. */
  .canopy-main:has(.canopy-outline) {
    max-width: calc(var(--content-max-width) + var(--sp-6) + 14rem);
    display: grid;
    grid-template-columns: minmax(0, 1fr) 14rem;
    column-gap: var(--sp-6);
    align-items: start;
  }
  .canopy-content { grid-column: 1; grid-row: 1; }
  .canopy-backlinks { grid-column: 1; grid-row: 2; }

  /* Grid row 1 / 3 spans both .canopy-content and .canopy-backlinks, so the
     outline can stay sticky for the full length of the article rather than
     just its own (much shorter) row. align-self: start is the same fix
     .canopy-sidebar already needed above: without it, a grid item stretches
     to match its spanned rows' combined height, and position: sticky has
     nothing to do inside a box that's already as tall as the space it could
     move through. */
  .canopy-outline {
    grid-column: 2;
    grid-row: 1 / 3;
    align-self: start;
    position: sticky;
    top: var(--sp-8);
    margin: 0;
  }
}

.canopy-backlinks {
  margin-top: var(--sp-8);
  padding-top: var(--sp-6);
  border-top: 1px solid var(--border);
  font-size: 0.9em;
}
.canopy-backlinks h2 { font-size: 1em; color: var(--text-muted); }
.canopy-backlinks a { color: var(--accent); }
.canopy-backlinks a:hover { color: var(--accent-hover); }

/* Prev/next cards. margin-left: auto on .canopy-next is the same technique
   .canopy-search/.canopy-theme-toggle use to sit at the far edge of their
   row — here it keeps "next" flush right even when "prev" is absent (the
   first page), the same way it keeps "next" flush right in the top bar
   whether or not search sits before it. No grid placement is given for the
   wide layout below: unplaced, this lands in the next auto-placed row of
   column 1 (.canopy-outline's sticky span covers rows 1/3 — .canopy-content
   and .canopy-backlinks — so the outline tracks the article and stops
   there, not stretching beside cards that are no longer part of it). */
.canopy-page-nav {
  display: flex;
  gap: var(--sp-4);
  margin-top: var(--sp-8);
  padding-top: var(--sp-6);
  border-top: 1px solid var(--border);
}
.canopy-page-nav a { color: var(--accent); text-decoration: none; }
.canopy-page-nav a:hover { color: var(--accent-hover); text-decoration: underline; }
.canopy-prev::before { content: "← "; }
.canopy-next { margin-left: auto; text-align: right; }
.canopy-next::after { content: " →"; }

/* Shiki dual-theme: swap to the dark palette via the CSS variables Shiki
   emits (--shiki-dark*), so code blocks match the page's color scheme. Both
   paths tokens.ts's own palette already resolves through — the system
   preference, and an explicit data-theme override that wins regardless of
   it. The media query alone left a code block on the light palette for a
   reader whose system prefers light but who clicked the toggle into dark:
   every other pixel on the page went dark (data-theme drives it), the code
   block did not (nothing here read data-theme at all). */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .shiki,
  :root:not([data-theme="light"]) .shiki span {
    color: var(--shiki-dark) !important;
    background-color: var(--shiki-dark-bg) !important;
  }
  /* The scroll shadow's cover pair above is colored for github-light's
     #fff — swapped here to --shiki-dark-bg (same variable the override just
     above reads) so a covered shadow still reads as absent, not as a
     mismatched light patch on a dark block. The shadow pair itself
     (rgba(0, 0, 0, 0.15)) is unchanged: still a readable darkening over
     --shiki-dark-bg, the same way it is over #fff. background-size/
     position/attachment are the base rule's own, untouched — only the
     four gradients' colors differ here. */
  :root:not([data-theme="light"]) .canopy-content pre {
    background-image:
      linear-gradient(to right, var(--shiki-dark-bg) 60%, transparent),
      linear-gradient(to left, var(--shiki-dark-bg) 60%, transparent),
      linear-gradient(to right, rgba(0, 0, 0, 0.15), transparent),
      linear-gradient(to left, rgba(0, 0, 0, 0.15), transparent);
  }
}
:root[data-theme="dark"] .shiki,
:root[data-theme="dark"] .shiki span {
  color: var(--shiki-dark) !important;
  background-color: var(--shiki-dark-bg) !important;
}
:root[data-theme="dark"] .canopy-content pre {
  background-image:
    linear-gradient(to right, var(--shiki-dark-bg) 60%, transparent),
    linear-gradient(to left, var(--shiki-dark-bg) 60%, transparent),
    linear-gradient(to right, rgba(0, 0, 0, 0.15), transparent),
    linear-gradient(to left, rgba(0, 0, 0, 0.15), transparent);
}

@media (max-width: 40rem) {
  /* The ancestor trail repeats what the sidebar's own expanded-to-current-item
     state already shows once a reader opens it, and on a narrow screen it was
     the one topbar entry likely to wrap onto a line of its own — dropped here
     rather than left to fight the title/search/theme row for space. */
  .canopy-breadcrumb { display: none; }
  /* Collapsed to just its icon so the title row has room for the theme toggle
     beside it instead of wrapping to a row of its own — the input is still a
     real, focusable text field, not a button that opens something else, so
     this has to keep working with no script (see docs/SCOPE.md). The width
     is set on the input itself, not on .canopy-search — .canopy-search has no
     size of its own beyond its child's, so shrinking the input is what shrinks
     the whole control, and .canopy-search's un-widthed box keeps sizing to
     content exactly like it did before this rule, which is what keeps it on
     the same flex line as the title/home link and, via the shared-wrapper
     rule above, right next to the theme toggle rather than each wrapping to a
     row of its own.
     Expansion is keyed off :focus-within on the form, not :focus on the
     input: a caller's search script (see docs/SCOPE.md) renders its results
     list as a child of this same .canopy-search form, so a reader tapping a
     result moves focus to a link inside the form, not away from it — :focus
     alone would collapse the box out from under a tap in progress before the
     click could land. */
  .canopy-search input[type="search"] {
    width: 2.25rem;
    transition: width 0.15s ease;
  }
  .canopy-search:focus-within input[type="search"] { width: 12rem; }

  /* One column now, so there is no column boundary left for the desktop
     gradient (above) to mark — reset to a plain fill. .canopy-sidebar takes
     its tint back here instead: stacked above .canopy-main rather than
     beside it, its own (content-sized) box is the whole of its row, so
     painting the tint there again reaches exactly as far as the gradient
     would have.
     min-height: auto releases the desktop min-height: 100vh floor, which
     existed only so that gradient reached the bottom of a short page — moot
     now that the background is a plain fill matching body's own (found live,
     dogfooding this shell: left in place, a single-column grid's implicit
     rows default to align-content: stretch, so the two rows (sidebar, main)
     were each stretched to fill 100vh between them even when their actual
     content was a small fraction of that — a closed nav's one-line disclosure
     rendered inside a tall, mostly-empty tinted box instead of hugging its
     own content, which read as broken rather than as a compact menu bar). */
  .canopy-layout {
    grid-template-columns: 1fr;
    min-height: auto;
    background: var(--bg-primary);
  }
  .canopy-sidebar {
    background: var(--bg-secondary);
    border-bottom: 1px solid var(--border);
    /* Release the desktop pin: a single-column layout has no "beside" for the
       sidebar to stay pinned against. The desktop max-height: 100vh is inert
       here anyway — the capped nav below keeps the sidebar's own content well
       under a full screen — but position: static (not sticky) is what actually
       matters, since a stray sticky element in a single column would otherwise
       still try to pin itself as the page scrolls. */
    align-self: auto;
    position: static;
  }
  /* Closed, the disclosure is just this control's own line — a cap would be
     inert here (there is nothing under it to clamp) but is declared anyway so
     an open disclosure that gains content still has one; see [open] below for
     what actually bounds it while open. */
  .canopy-nav > nav { max-height: 25vh; overflow-y: auto; }
  /* An icon, not the native disclosure marker: a visible word would have to be
     written in the site's language, which canopy cannot know — the same
     reason --home-label has no default. aria-label carries the meaning either
     way. list-style: none removes the native marker so ::before's icon is the
     only glyph. The [open] form is repeated (rather than left to the plain
     selector) because it is more specific and would otherwise win at every
     width, hiding the control on narrow screens too. */
  .canopy-nav[open] > summary,
  .canopy-nav > summary {
    display: flex;
    align-items: center;
    list-style: none;
    cursor: pointer;
    padding: var(--sp-3) 0;
    min-height: 2.75rem;
  }
  .canopy-nav[open] > summary::before,
  .canopy-nav > summary::before {
    content: "";
    width: 1.25rem;
    height: 1.25rem;
    background-color: var(--text-muted);
    -webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M3%2012h18M3%206h18M3%2018h18'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
    mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M3%2012h18M3%206h18M3%2018h18'%2F%3E%3C%2Fsvg%3E") center / contain no-repeat;
  }
  /* Open swaps the icon to an "x": the control itself says what clicking it
     now does, rather than leaving the reader to infer "close" from the panel
     simply vanishing. */
  .canopy-nav[open] > summary::before {
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M18%206%206%2018M6%206l12%2012'%2F%3E%3C%2Fsvg%3E");
    mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M18%206%206%2018M6%206l12%2012'%2F%3E%3C%2Fsvg%3E");
  }

  /* Open, the disclosure becomes a full-screen panel instead of the in-flow,
     25vh-capped block above. Replaces that clamp-and-scroll treatment for
     both of its defects: a full nav that pushed page content down when open
     (the original problem), and — found live, dogfooding this shell — a
     closed control that still sat inside an oversized tinted band (.canopy-
     sidebar's own padding/background), which read as a layout that had
     broken rather than a compact menu bar. A full-screen panel has no
     "oversized when closed" state to have, because closed is once again just
     this control's own line — the fix removes the second defect's cause
     rather than clamping its symptom. */
  .canopy-nav[open] {
    position: fixed;
    inset: 0;
    z-index: 20;
    display: flex;
    flex-direction: column;
    padding: 0 var(--sp-4);
    background: var(--bg-secondary);
    overflow-y: auto;
  }
  .canopy-nav[open] > nav {
    max-height: none;
    flex: 1;
  }
  /* The page behind a full-screen panel must not also scroll — scoped to this
     breakpoint alone, since .canopy-nav ships [open] in the HTML unconditionally
     (no JS to remember a closed state — SCOPE.md's no-JS non-goal) and this
     selector would otherwise hide scrolling on every desktop page too. */
  body:has(.canopy-nav[open]) { overflow: hidden; }
}
