/* ==========================================================================
   main.css – tokens, layout, typography. No framework, no build step.
   Metrics copied 1:1 from the v2 docs site (header, controls, sidebar,
   toc, prose, search dialog). Theme contract: JS sets
   <html data-theme="dark|light">; colour-scheme follows, which is what
   makes Zola's inline light-dark() code colours flip.
   ========================================================================== */

/* ---------- Design tokens (mirrored from the docs-site palette) ---------- */
:root,
html[data-theme="dark"] {
  --bg: #090909;
  --fg: #efefef;
  --muted: #929292;
  --border: #303030;
  --surface: #101010;
  --surface-hover: #181818;
  /* --accent: the brand red from logo.svg (#ff2600). Used everywhere the
     site expresses brand identity: links, focus rings, primary buttons,
     selection, the hero dot. On light backgrounds it steps down to a
     same-hue deep red (#d92000 below) — the exact brand red only clears
     4.5:1 text contrast over the dark background, not over white.
     --blue stays a tone token (pill / callout palettes), not the accent. */
  --accent: #ff2600;
  --link: #ff2600;
  --blue: #73a7ff;
  --green: #70c997;
  --yellow: #e0af68;
  --red: #e67c7c;
  --on-accent: #0a0a0a;
  --tip-border: #28503a;
  --tip-background: #0b1b11;
  --warning-border: #62471f;
  --warning-background: #1c1408;
  --backdrop: #000000b8;
  /* Structural separator lines (header, sidebar, mobile strip, rows, hr,
     table rules, footer): the v2 docs site renders its header hairline as a
     70% mix over transparent; we keep that exact tone but mix over --bg so
     the line is opaque. A translucent line composites to a different pixel
     depending on the layer it lands on – the sticky, scrollable sidebar's
     GPU layer rounded the blend to rgb(37) while every other line rounded
     to rgb(36) (measured; the docs site has the same ±1 quirk). Mixing over
     --bg computes the same color (36 dark / 235 light) once, up front, so
     all lines match everywhere, in both themes (both vars resolve per
     theme on <html>). Cost: content no longer ghosts through the 1px line
     under the sticky header. Component outlines – buttons, chips, code
     blocks, photos, panels – keep the full-strength --border. */
  --separator: color-mix(in srgb, var(--border) 70%, var(--bg));
  /* Current-item highlight – neutral grays of the v2 docs palette
     (Dark: #181818 is exactly --surface-hover, as claimed above. Light
     deliberately sits one step lighter, on --surface: #ededed would read as
     a hover flicker over the #f6f6f6 surfaces; the highlight stays distinct
     through the bold text plus the 2px --active-item-edge strip.) */
  --active-item-bg: #181818;
  --active-item-edge: #1f1f1f;
  color-scheme: dark;
}

/* Structural metrics must sit on a plain :root, never under a theme
   selector. In a list like `:root, html[data-theme="dark"]` these same
   declarations match <html> at 0-1-1 while dark, which beats the mobile
   override further down (`@media (max-width: 48rem) { :root { --gutter:
   1rem } }`, 0-1-0) – the gutter, and every padded element with it,
   jumped when the theme toggled below 768px. On a plain :root the media
   query wins in both themes, exactly like the v2 docs site. */
:root {
  --header-height: 3.875rem;
  --frame-width: 96rem;
  --content-width: 60rem;
  --gutter: 1.5rem;
}

html[data-theme="light"] {
  --bg: #ffffff;
  --fg: #1a1a1a;
  --muted: #6b6b6b;
  --border: #e2e2e2;
  --surface: #f6f6f6;
  --surface-hover: #ededed;
  --accent: #d92000;
  --link: #d92000;
  --blue: #1d63d8;
  --green: #1a7f4b;
  --yellow: #9a6700;
  --red: #c62f2f;
  --on-accent: #ffffff;
  --tip-border: #b5dcc3;
  --tip-background: #eff9f2;
  --warning-border: #ebd2a4;
  --warning-background: #fdf6e8;
  --backdrop: #00000052;
  --active-item-bg: #f6f6f6;
  --active-item-edge: #efefef;
  color-scheme: light;
}

/* No-JS: follow the OS preference until the toggle is possible at all. */
@media (prefers-color-scheme: light) {
  html:not([data-theme]) {
    --bg: #ffffff; --fg: #1a1a1a; --muted: #6b6b6b; --border: #e2e2e2;
    --surface: #f6f6f6; --surface-hover: #ededed; --accent: #d92000;
    --link: #d92000;
    --blue: #1d63d8; --green: #1a7f4b; --yellow: #9a6700; --red: #c62f2f;
    --on-accent: #ffffff; --backdrop: #00000052;
    --tip-border: #b5dcc3; --tip-background: #eff9f2;
    --warning-border: #ebd2a4; --warning-background: #fdf6e8;
    --active-item-bg: #f6f6f6; --active-item-edge: #efefef;
    color-scheme: light;
  }
}

/* ---------- Fonts (vendored OpenTUI Mono, SIL OFL) ---------- */
@font-face {
  font-family: "OpenTUI Mono";
  src: url("fonts/OpenTUIMono-Regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "OpenTUI Mono";
  src: url("fonts/OpenTUIMono-Bold.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "OpenTUI Mono";
  src: url("fonts/OpenTUIMono-Italic.woff2") format("woff2");
  font-weight: 400; font-style: italic; font-display: swap;
}
@font-face {
  font-family: "OpenTUI Mono";
  src: url("fonts/OpenTUIMono-BoldItalic.woff2") format("woff2");
  font-weight: 700; font-style: italic; font-display: swap;
}

/* ---------- Base (docs-site metrics) ---------- */
*, *::before, *::after { box-sizing: border-box; }
* { scrollbar-color: var(--border) transparent; scrollbar-width: thin; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; }

html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; scrollbar-gutter: stable; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  margin: 0;
  font-family: "OpenTUI Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
  font-size: 0.875rem;           /* docs-site metric: 14px */
  line-height: 1.8em;
  color: var(--fg);
  background: var(--bg);
}
button, input { color: inherit; font: inherit; }

::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); }

a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }

img { max-width: 100%; height: auto; }

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

.skip {
  position: absolute; left: -999px; top: 0; z-index: 100;
  background: var(--fg); color: var(--bg);
  padding: 0.5rem 0.75rem; border: 0; border-radius: 0;
}
.skip:focus { left: 1rem; top: 1rem; }

.icon { vertical-align: -0.15em; }

/* ---------- Header (1:1 with the docs site) ---------- */
.site-header {
  position: sticky; top: 0; z-index: 50;
  height: var(--header-height);
  /* Docs-identical hairline: the v2 docs site's exact 70% color-mix, now
     tokenized as --separator and shared by every structural line. */
  border-bottom: 1px solid var(--separator);
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
}
.header-inner {
  height: 100%;
  display: flex; align-items: center; gap: 1.5rem;
  padding-inline: var(--gutter);
}
.brand { display: inline-flex; align-items: center; min-width: 0; color: var(--fg); }
.brand:hover { text-decoration: none; }
/* Dot wordmark (logo.svg pulled in via <use>, ~7.4:1): the ink path is
   currentColor so it follows the theme, the accent dot stays #ff2600.
   width:auto + the 897x121 viewBox keeps the lockup's ratio; max-width and
   the .brand min-width:0 let it scale down instead of squeezing the header
   controls when a window gets very narrow. */
.brand-logo { height: 1.375rem; width: auto; max-width: 100%; }
.site-nav { display: flex; align-items: center; gap: 1.25rem; }
.site-nav a, .nav-link {
  color: var(--muted); font-weight: 700;
  padding-block: 0.25rem; border-radius: 0;
}
.site-nav a:hover, .nav-link:hover { color: var(--fg); text-decoration: none; }
.site-nav a.active, .nav-link.active { color: var(--fg); }
.header-spacer { flex: 1; }

/* Header controls share one square: 2.25rem tall, 1px border, hover
   lightens the border instead of adding a background. */
.search-trigger {
  display: inline-flex; align-items: center; justify-content: space-between;
  min-width: 9.5rem; height: 2.25rem; padding: 0 0.75rem;
  color: var(--muted); background: transparent;
  border: 1px solid var(--border); border-radius: 0; cursor: pointer;
  flex-shrink: 0;
}
.search-trigger:hover { border-color: var(--muted); color: var(--fg); }
.search-trigger kbd, .search-trigger .kbd { font: inherit; font-size: 0.75rem; margin-left: auto; }
.theme-toggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem; padding: 0;
  color: var(--muted); background: transparent;
  border: 1px solid var(--border); border-radius: 0; cursor: pointer;
  flex-shrink: 0;
}
.theme-toggle:hover { border-color: var(--muted); color: var(--fg); }
html:not(.js) .theme-toggle { display: none; }
html:not(.js) .search-trigger { display: none; }
html[data-theme="dark"] .icon-moon { display: none; }
html[data-theme="light"] .icon-sun { display: none; }
.kbd { font: inherit; font-size: 0.75rem; }

/* Mobile menu: at <=48rem a sticky glass strip below the header with the
   nav links inline and centered (shown by the 48rem block). */
.mobile-navigation { display: none; }

/* ---------- Shell / layout variants ---------- */
.frame { width: min(100%, var(--frame-width)); margin-inline: auto; }

main {
  display: grid;
  min-height: calc(100vh - var(--header-height));
  /* Mobile browsers resize the URL bar while scrolling: svh is the visible
     height with the bars expanded, so the page doesn't overshoot. Engines
     without svh keep the vh line above. */
  min-height: calc(100svh - var(--header-height));
  align-items: start;
}
.layout-list { grid-template-columns: 15rem minmax(0, 1fr); }
.layout-doc  { grid-template-columns: 15rem minmax(0, 1fr) 16rem; }
/* ToC-only doc pages (page.html without a sidebar): the DOM order is
   content-then-toc, so the columns flip compared to .layout-doc – using
   that one here would put the article in the 15rem sidebar track. */
.layout-toc  { grid-template-columns: minmax(0, 1fr) 16rem; }
.layout-home { grid-template-columns: minmax(0, 1fr); }
.layout-page { grid-template-columns: minmax(0, 1fr); }

/* Content column: docs-site main padding */
.content { padding: 3rem clamp(2rem, 4vw, 4rem) 5rem; }
.layout-home .content { margin-inline: auto; max-width: var(--content-width); padding: 4.5rem var(--gutter) 5rem; }

/* Left sidebar (contextual – never a copy of the top menubar).
   docs-site .sidebar-column + .docs-sidebar metrics. */
.sidebar {
  position: sticky; top: var(--header-height);
  /* vh fallback first, svh (visible viewport) second – same mobile URL-bar
     pairing as .main and .toc above. */
  height: calc(100vh - var(--header-height));
  height: calc(100svh - var(--header-height));
  overflow-y: auto;
  border-right: 1px solid var(--separator);
  padding: 2.25rem 2rem 3rem var(--gutter);
}
.side-title, .toc-title {
  margin: 0 0 0.625rem; font-size: 0.75rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.04em; color: var(--fg);
}
.side-list { list-style: none; margin: 0; padding: 0; }
.side-list li + li { margin-top: 0.375rem; }
.side-list a {
  display: flex; flex-direction: column; gap: 0.1rem;
  color: var(--muted); padding-block: 0.125rem;
}
.side-list a:hover { color: var(--fg); text-decoration: none; }
.side-list a.active { color: var(--fg); font-weight: 700; }
/* Folder-style tags in the filter sidebar (kind = "folder" in tag_meta): a
   name, not a hashtag. Same shade as the topic tags – the small square marker
   plus the name itself is the only distinction. Display only: the tag itself
   stays flat. */
.side-list a.tag-folder {
  flex-direction: row; align-items: center; gap: 0.4rem;
}
.side-list a.tag-folder::before {
  content: ""; flex: 0 0 auto;
  width: 0.375rem; height: 0.375rem; border: 1px solid currentColor;
}
/* Filter active (site.js toggles .active on every [data-tag-filter] link):
   the square fills with the link's own colour – solid fg in the sidebar
   (.side-list a.active sets color: var(--fg)), muted on in-page chips.
   Works in both themes; covers the .chip-folder copies of the same marker. */
.side-list a.tag-folder.active::before, .chip-folder.active::before { background: currentColor; }
.side-link-title { color: inherit; }
.side-link-meta { font-size: 0.75rem; color: var(--muted); }

/* Right TOC: exactly the docs-site .toc-column + .docs-toc metrics – plain,
   no panel. Inline padding lives on the links themselves (as on the docs
   site) so the active-item highlight can run edge to edge. */
.toc {
  position: sticky; top: calc(var(--header-height) + 2rem);
  max-height: calc(100vh - var(--header-height) - 4rem);
  max-height: calc(100svh - var(--header-height) - 4rem);
  overflow-y: auto;
  padding: 3rem 0;
  /* No visible scrollbar chrome on the TOC (docs-site look) while it stays
     internally scrollable for the scroll-spy nudge. Must come after the
     global thin-scrollbar rules to win on specificity. */
  scrollbar-width: none;
}
.toc::-webkit-scrollbar { width: 0; }
.toc-title { padding-inline: 2rem var(--gutter); }
.toc-list { list-style: none; margin: 0; padding: 0; }
.toc-list li + li { margin-top: 0.375rem; }
.toc-list ul { list-style: none; margin: 0; padding: 0; }
.toc-item a {
  display: block; color: var(--muted);
  padding: 0.125rem var(--gutter) 0.125rem 2rem;
}
.toc-list ul .toc-item a { padding-left: 3rem; }
.toc-item a:hover { color: var(--fg); text-decoration: none; }
/* Active heading while scrolling – the highlight opencode.ai/docs gives the
   current page in its left sidebar: background block + 2px edge strip. */
.toc-item a.active {
  color: var(--fg); font-weight: 700;
  background: var(--active-item-bg);
  border-left: 2px solid var(--active-item-edge);
  padding-left: calc(2rem - 2px);
}
.toc-list ul .toc-item a.active { padding-left: calc(3rem - 2px); }

/* ---------- Page headers ---------- */
.page-head { margin-bottom: 2rem; }
.page-head h1 { margin: 0.25rem 0 0.5rem; font-size: 1.875rem; line-height: 1.2; font-weight: 700; }
/* Long-word insurance outside .prose (headings/cards/rows may hold slugs or
   URLs; overflow-wrap only breaks them when they would overflow). */
.page-head h1, .card h3, .row-body h2 { overflow-wrap: break-word; }
.page-desc { margin: 0; color: var(--muted); max-width: 46rem; }
.crumbs { margin: 0; font-size: 0.75rem; }
.crumbs a { color: var(--muted); }
.crumbs a:hover { color: var(--fg); }
.article-meta {
  display: flex; flex-wrap: wrap; gap: 0.625rem; align-items: center;
  margin: 0.75rem 0 0; color: var(--muted);
}
.meta-org { color: var(--fg); }

/* ---------- Prose (docs-site rhythm) ---------- */
.prose { max-width: var(--content-width); overflow-wrap: break-word; }
.prose h1, .prose h2, .prose h3, .prose h4 {
  color: var(--fg); font-weight: 700;
  scroll-margin-top: calc(var(--header-height) + 1rem);
}
.prose h1 { margin: 0 0 3rem; font-size: 1.875rem; line-height: 1.2; }
.prose h2 { margin: 3rem 0 0.75rem; font-size: 1.25rem; line-height: 1.5; }
.prose h3 { margin: 2.25rem 0 0.75rem; font-size: 1.125rem; line-height: 1.5; }
.prose h4 { margin: 2rem 0 0.75rem; font-size: 1rem; }
.prose > :first-child { margin-top: 0; }
.prose p, .prose ul, .prose ol { margin: 0 0 1.5rem; }
.prose ul, .prose ol { padding-left: 1.5rem; }
.prose li + li { margin-top: 0.5rem; }
/* Bullet markers: not round – the site's squares, same 6px marker with the
   1px border the folder tags use (.tag-folder::before). Top level filled,
   nested hollow: the old disc/circle logic, in square form. The swap rides
   on :has() support – the same condition the task-list rule below needs;
   old engines keep the browser's default bullets. */
@supports selector(:has(*)) {
  .prose ul { list-style: none; }
  .prose ul > li { position: relative; }
  .prose ul > li:not(:has(> input[type="checkbox"]))::before {
    content: ""; position: absolute; left: -1.25rem;
    /* Optical centring, bracketed by builds: the geometric line-box centre
       (middle at 12.6 under the line-box top) read a hair high against
       prose, an all-lowercase build at 13.0 read a hair low; the kept
       value for these markers is ~12.9, so +0.3px over geometric centre.
       At dsf 2 that puts the edge on a fractional device pixel (227.4):
       Chrome never snaps positioned layers, it antialiases – a ~0.1px
       feather, the same softness the 1px frame already carries here.
       The nudge is for bullets against word bands only – the checkboxes
       below and the folder chip squares keep the exact line-box centre.
       0.375rem = the square's border-box height. */
    top: calc((1lh - 0.375rem) / 2 + 0.3px);
    width: 0.375rem; height: 0.375rem;
    border: 1px solid currentColor; background: currentColor;
  }
  .prose ul ul > li:not(:has(> input[type="checkbox"]))::before { background: none; }
}
/* Task lists (`- [ ]` / `- [x]`): Zola emits a disabled checkbox as the
   li's first child but keeps the bullet – the bullet goes for those items
   only, the checkbox rides where it was. A single-level :has() selector,
   no nesting. The native control (round corners on macOS) is squared off:
   hairline border, empty fill, sharp corners – same square family as the
   markers and folder tags; a checked item fills the square with the text
   colour (white in dark, ink in light), like the filled top-level bullet.
   The -1.26px nudge puts its middle at 12.6 under the line-box top –
   exact line-box centre (12.6 under the line-box top), where the folder
   chips sit too. The list markers above carry a +0.3px optical nudge;
   this box does not – it centres on its own check glyph, not a baseline.
   (vertical-align: middle alone lands it at 13.86). */
.prose li:has(> input[type="checkbox"]) { list-style: none; }
.prose li > input[type="checkbox"] {
  appearance: none; -webkit-appearance: none;
  width: 0.875rem; height: 0.875rem; margin: 0 0.5em 0 0;
  border: 1px solid var(--muted); background: none; border-radius: 0;
  vertical-align: middle;
  position: relative; top: -1.26px;
}
.prose li > input[type="checkbox"]:checked { border-color: var(--fg); background: var(--fg); }
/* Footnotes (bottom_footnotes = true in zola.toml): the markers are
   <sup class="footnote-reference"> in the text; Zola appends the list as
   <section class="footnotes"><ol class="footnotes-list"> at the end of
   the page content, back-reference links riding inside each item. */
.prose .footnotes {
  margin-top: 3rem; padding-top: 1rem;
  border-top: 1px solid var(--separator);
  color: var(--muted); font-size: 0.8125rem;
}
.prose .footnotes p { margin: 0; }
.prose .footnotes li + li { margin-top: 0.5rem; }
/* Jump targets need the same sticky-header clearance as the headings
   (scroll-margin-top there), or the hash jump parks them under the
   header: the <sup> markers (id="fr-…", hit by the ↩ back-links) and the
   bottom-list items (id="fn-…", hit by the [n] forward links). */
.prose .footnote-reference, .prose .footnotes li {
  scroll-margin-top: calc(var(--header-height) + 1rem);
}
.prose a { color: var(--link); }
.prose hr { border: 0; border-top: 1px solid var(--separator); margin: 3rem 0 1.5rem; }
.prose img { max-width: 100%; height: auto; }

/* Anchor links: hidden until the heading is hovered (docs-site trick) */
/* Zola injects a link-emoji glyph into heading anchors; render it as a plain # instead.
   The <a> text (font-size: 0, line-height: 0) stays zero-boxed so it never
   shifts the heading's line; the replacement # comes from ::after.
   The 0.75ch gap hangs on ::after, NOT on the <a>: minify_html strips any
   whitespace between the heading text and the anchor, and a margin on the
   <a> itself is hopeless – ch resolves against the <a>'s own font-size,
   which is 0 here, so 0.75ch would compute to 0. On ::after the unit
   resolves against the heading-sized font-size the # actually renders at. */
.zola-anchor { opacity: 0; color: var(--muted); font-weight: 400; font-size: 0; line-height: 0; }
/* The # scales with its heading: the <a> itself must stay font-size:0
   (that is what zero-boxes Zola's 🔗 glyph so headings never shift), and
   em inside ::after would resolve against that 0 – so mirror the heading
   sizes explicitly: h1-h4 copy the prose metrics, h5/h6 have none (the
   docs-site prose stops at h4 too), so mirror the UA defaults (0.83em /
   0.67em) against the 0.875rem body size. */
.zola-anchor::after { content: "#"; margin-left: 0.75ch; }
h1 .zola-anchor::after { font-size: 1.875rem; }
h2 .zola-anchor::after { font-size: 1.25rem; }
h3 .zola-anchor::after { font-size: 1.125rem; }
h4 .zola-anchor::after { font-size: 1rem; }
h5 .zola-anchor::after { font-size: calc(0.875rem * 0.83); }
h6 .zola-anchor::after { font-size: calc(0.875rem * 0.67); }
h1:hover .zola-anchor, h2:hover .zola-anchor,
h3:hover .zola-anchor, h4:hover .zola-anchor,
h5:hover .zola-anchor, h6:hover .zola-anchor,
.zola-anchor:focus { opacity: 1; }
/* Touch devices never hover: the # stays invisible there yet still occupies
   space and remains tappable, so remove it entirely on hover-less input. */
@media (hover: none) { .zola-anchor { display: none; } }

/* Inline code: docs-site metric (tinted background, no border) */
.prose code, .row code, .card code { font: inherit; }
.prose :not(pre) > code, .row :not(pre) > code, .card :not(pre) > code {
  background: color-mix(in srgb, currentColor 10%, transparent);
  padding: 0.0625rem 0.4ch; border-radius: 0;
}

/* Fenced code: Zola inline dual-theme -> colours arrive as light-dark().
   The !important background mirrors the docs site, which also overrides the
   theme's own block fill: the code sits on the page background inside a
   plain 1px frame – no separate panel colour, in either theme. */
.prose pre {
  border: 1px solid var(--border); border-radius: 0;
  margin: 0 0 1.5rem; padding: 0.875rem 1rem;
  font: inherit; font-size: 0.8125rem;
  overflow-x: auto; tab-size: 4;
  background: var(--bg) !important;
}
.prose pre code { background: none; padding: 0; }
.prose pre.giallo { white-space: pre; }

/* Code-block line spans (Zola 0.23): the highlighter emits one .giallo-l
   per source line (and a .giallo-ln span per number with linenos), the
   lines separated by literal newlines. The spans become block boxes so
   line-wide decorations reach edge-to-edge; white-space:normal on the
   code element makes those separator newlines block-level whitespace,
   which is dropped, while white-space:pre inside each span keeps every
   character. Auto width + negative inline margins (the pre's horizontal
   padding) bleed the boxes to the frame, so an hl_lines tint – Zola
   bakes it as an inline light-dark() background on the span – becomes a
   full-width band. min-height:1lh keeps a blank line's empty span on the
   grid. The 1rem text inset is a generated ::before spacer, never
   padding: some engines paint the selection fill across a line's whole
   padding box, which drags the highlight into the empty inset, while
   generated content is never part of a selection. Metrics are
   untouched: font-size and the inherited line-height simply pass
   through. Verified in headless Chrome, pixel by pixel. */
.prose pre.giallo code { white-space: normal; }
.prose .giallo-l {
  display: block; white-space: pre;
  margin-inline: -1rem;    /* box bleeds to the frame … */
  min-height: 1lh;
}
.prose .giallo-l::before {
  content: "";             /* … the 1rem text inset is this spacer: as
                              generated content it is never part of a
                              selection, so the highlight always starts
                              at the text – some engines would paint a
                              line's padding box and drag the fill into
                              the empty inset, as the numbered blocks
                              (counter ::before) already prove safe */
  display: inline-block; width: 1rem;
}
/* hl_lines fill: Zola bakes the *theme's* highlight colour into the span
   as an inline light-dark() background (the github themes ship #f6f8fa /
   #2b3036 – greyed-out syntax colours, not this site's palette). The docs
   palette has one neutral current-item fill: --surface-hover (#181818
   dark / #ededed light) – what the docs use to highlight rows. Use it,
   !important beats Zola's inline style; only tinted lines carry a
   background attribute, so the selector hits exactly the hl_lines. */
.prose .giallo-l[style*="background"],
.prose .giallo-ln[style*="background"] {
  background-color: var(--surface-hover) !important;
}
/* linenos gutter: Zola bakes the numbers into each line as real text
   spans – digits a reader can highlight and copy. The stylesheet hides
   those spans and draws the numbers as generated content (a CSS counter
   on the line boxes): the gutter becomes pure layout, so no digit can
   land in a selection or the clipboard – drag, triple-click or Cmd+A,
   in any browser. Spacing is symmetric: 1rem from the frame to the
   number, 1rem from the number to the code – the same inset regular
   paragraphs use. The numbers are left-aligned. When a block's numbers
   cross 9 -> 10, site.js adds .ln-pad and the single-digit ones are
   zero-padded ("01"), so every line in the block keeps one code column
   and no digit shift moves the text. The colour is the site's own
   --muted rather than the themes' grey. site.js offsets the counter
   start for linenostart=.
   Without :has support (older browsers) the real spans stay put, with
   the spacer before them and the same 1rem gap after, as the softer
   previous fallback. The counter box also replaces the plain spacer
   above: margin-left restores the 1rem inset, so one ::before rule per
   line serves both block types. */
.prose pre.giallo:has(.giallo-ln) .giallo-ln { display: none; }
.prose pre.giallo:has(.giallo-ln) code { counter-reset: ln; }
.prose pre.giallo:has(.giallo-ln) .giallo-l { counter-increment: ln; }
.prose pre.giallo:has(.giallo-ln) .giallo-l::before {
  content: counter(ln);
  display: inline-block; width: auto;
  min-width: 1ch; margin-left: 1rem; margin-right: 1rem;
  text-align: left; color: var(--muted);
}
.prose pre.giallo:has(.giallo-ln) code.ln-pad .giallo-l::before {
  content: counter(ln, decimal-leading-zero);
}
.prose .giallo-ln {
  display: inline-block; min-width: 1ch; margin-right: 1rem;
  text-align: left; user-select: none; -webkit-user-select: none;
}
/* Copy button (docs site .docs-code-block): site.js wraps every fenced
   block in a position:relative .docs-code-box div and drops a small
   button in the block's top-right corner – surface fill, muted 0.75rem
   text, no border, exactly like the docs. The label flips to "Copied"
   for 1.5s. Blocks that already sit inside tabs lose their own frame
   (the tab box owns it), as in the docs. */
.docs-code-box { position: relative; }
.docs-code-box > button {
  cursor: pointer; position: absolute; top: 0.5rem; right: 0.5rem; z-index: 1;
  background: var(--surface); color: var(--muted);
  border: 0; border-radius: 0; padding: 0.25rem 0.5rem;
  font: inherit; font-size: 0.75rem;
}
.docs-code-box > button:hover { color: var(--fg); }

/* Filename bar (fence suffix name=…, see components.md): with
   data_attr_position = "pre" in zola.toml the <pre> carries data-name;
   site.js mirrors it onto the .docs-code-box wrapper, and the bar is
   generated on that wrapper – above the horizontal scroller, so it
   stays put when a long line scrolls sideways (Zola's pre kept its own
   padding, exactly like an untitled block). The strip carries the
   frame's top and sides plus the hairline under it; the pre drops its
   top border so the rule under the strip is not doubled. Total strip
   height: 1px border + 0.5rem padding + 25.2px line (the site's line
   rhythm is an absolute length inherited from body) + 0.5rem padding +
   1px hairline = 43.2px. Type metric is the docs site's own title bar
   (astro-code-title): 0.75rem muted text, 0.5rem/1rem padding. */
.docs-code-box[data-name]::before {
  content: attr(data-name);
  display: block;
  padding: 0.5rem 1rem;
  border: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.75rem;
}
.docs-code-box[data-name] > pre { border-top: 0; }
/* Inside a code-tab box the tab frame owns the border – the strip only
   keeps its hairline. */
.docs-code-tabs .docs-code-box[data-name]::before {
  border: 0; border-bottom: 1px solid var(--border);
}
/* With a filename bar the button can't ride the frame's corner (the
   strip owns it), so it sits 0.5rem under the strip's hairline – the
   exact 0.5rem top/right inset the untitled blocks' corner button
   keeps. (The docs never pair a title bar with a button, so this
   pairing is ours.) */
.docs-code-box[data-name] > button { top: calc(43.2px + 0.5rem); }

/* Code tabs + content tabs (docs site .docs-code-tabs / .docs-plan-tabs).
   Components: <tabs> with <tab label="…"> bodies (templates/components.html).
   In the markdown a tab is just a .docs-tab-panel div with its label in
   data-tab-label; site.js reads the labels, builds the docs' own
   .docs-code-tabs-list / .docs-plan-tabs-list markup (role=tab buttons,
   hairline strip, selected tab gets a bottom rule) and hides the inactive
   panels. Without JS every panel simply renders stacked in reading order –
   the content is fully usable either way.
   <tabs box={false}> switches the plain underline style (docs plan tabs):
   no surrounding frame, 2px selection rule, bolder labels. */
.docs-code-tabs { border: 1px solid var(--border); margin: 0 0 1.5rem; }
.docs-code-tabs-list { border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; }
.docs-code-tabs-list button {
  cursor: pointer; color: var(--muted); font: inherit; font-size: 0.75rem;
  background: none; border: 0; border-bottom: 1px solid transparent;
  padding: 0.5rem 0.875rem; flex-shrink: 0; margin-bottom: -1px;
}
.docs-code-tabs-list button:hover,
.docs-code-tabs-list button[aria-selected=true] { color: var(--fg); }
.docs-code-tabs-list button[aria-selected=true] { border-bottom-color: var(--fg); }
.docs-code-tabs-list button:focus-visible { outline: 1px solid var(--fg); outline-offset: -3px; }
.docs-code-tabs .docs-code-box pre { border: 0; margin: 0; }
.docs-code-tabs .docs-tab-panel > :last-child { margin-bottom: 0; }

.docs-plan-tabs { margin-bottom: 1.5rem; }
.docs-plan-tabs-list { border-bottom: 1px solid var(--border); display: flex; gap: 0; margin-bottom: 1rem; }
.docs-plan-tabs-list button {
  cursor: pointer; color: var(--muted); font: inherit; font-weight: 600;
  background: none; border: 0; border-bottom: 2px solid transparent;
  padding: 0.5rem 1rem; margin-bottom: -1px;
}
.docs-plan-tabs-list button[aria-selected=true] {
  border-bottom-color: var(--fg); color: var(--fg);
}
.docs-plan-tabs-list button:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
.docs-plan-tabs .docs-tab-panel { overflow-x: auto; }
.docs-plan-tabs .docs-tab-panel > :last-child,
.docs-plan-tabs .docs-tab-panel > :last-child > :last-child { margin-bottom: 0; }

/* Tables (docs-site metric: rules under rows only) */
.prose table { border-collapse: collapse; width: 100%; margin-bottom: 1.5rem; font-size: 0.8125rem; }
.prose th, .prose td {
  border: 0; border-bottom: 1px solid var(--separator);
  text-align: left; vertical-align: top; padding: 0.5rem 1rem 0.5rem 0;
}
.prose th { color: var(--muted); font-size: 0.75rem; font-weight: 400; background: none; }

/* Blockquotes + GitHub-style alerts + the callout component. The box spec
   is the live docs site's .callout, 1:1: a plain 1px frame on the page
   background, 1rem padding, an uppercase bold label line (the docs render
   <strong>Note</strong>; here the label rides a ::before for the native
   alerts and a real <strong> for the component), and Tip/Warning take the
   palette's tinted border+fill tokens. Note/important/caution: the docs
   only ever show the plain frame (note) – the two extra GitHub tones
   extend it with the pills' tint formula so they never look broken. */
.prose blockquote {
  border-left: 2px solid var(--muted); color: var(--muted);
  margin: 0 0 1.5rem; padding-left: 1rem;
}
.prose blockquote p:last-child { margin-bottom: 0; }

/* Zola emits one combined class per alert (markdown-alert-tip, …), not a
   GitHub-style pair – hence [class*=…] attribute selectors. */
.prose blockquote[class*=markdown-alert], .callout {
  margin: 0 0 1.5rem; padding: 1rem; color: var(--fg);
  border: 1px solid var(--border); border-radius: 0;
}
.prose blockquote[class*=markdown-alert]::before, .callout > strong {
  display: block; text-transform: uppercase;
  font-weight: 700; margin-bottom: 0.5rem;
}
.prose blockquote[class*=markdown-alert] p:last-child, .callout > div > :last-child { margin-bottom: 0; }
.prose .markdown-alert-note::before      { content: "Note"; }
.prose .markdown-alert-tip::before       { content: "Tip"; }
.prose .markdown-alert-important::before { content: "Important"; }
.prose .markdown-alert-warning::before   { content: "Warning"; }
.prose .markdown-alert-caution::before   { content: "Caution"; }
/* Tone overrides carry the same compound shape so they outweigh the base
   rule above regardless of source order. */
.prose blockquote[class*=markdown-alert].markdown-alert-tip, .callout-tip       { border-color: var(--tip-border); background: var(--tip-background); }
.prose blockquote[class*=markdown-alert].markdown-alert-warning, .callout-warning { border-color: var(--warning-border); background: var(--warning-background); }
.prose blockquote[class*=markdown-alert].markdown-alert-important, .callout-important {
  border-color: color-mix(in srgb, var(--blue) 45%, transparent);
  background: color-mix(in srgb, var(--blue) 8%, transparent);
}
.prose blockquote[class*=markdown-alert].markdown-alert-caution, .callout-caution {
  border-color: color-mix(in srgb, var(--red) 45%, transparent);
  background: color-mix(in srgb, var(--red) 8%, transparent);
}

/* ---------- Timeline module (LinkedIn-style: rail + dots) ----------
   Components: {% <timeline> %} wrapper + {% <title> %} entries (see
   templates/components.html); live on /cv/, demoed on /components/.
   Org names are real markdown headings inside the wrapper (the page ToC
   keeps their anchors), but the rail belongs to the roles, not the orgs:
   headings draw no line, and each org gets its own continuous stroke
   running from the first role's dot to the last one. Entries paint a
   full-height ::before segment by default so consecutive roles under one
   heading connect; the group's first entry starts its segment at the dot
   (top: 0.75rem – the 6px ::after square centred on the role's first
   text line) and the group's last one stops there. A role alone in its
   org gets just the dot. All vertical spacing is padding, never margin,
   so segments butt together. */
.timeline { position: relative; padding-left: 1.5rem; }
.timeline > h2, .timeline > h3, .timeline .tl-title { position: relative; margin: 0; }
.timeline > h2, .timeline > h3 { padding: 1.75rem 0 0.5rem; }
.timeline > h2:first-child, .timeline > h3:first-child { padding-top: 0; }

/* Rail: role entries only – full block height, so roles within one org
   connect into a single stroke. Headings carry no line. */
.timeline .tl-title::before {
  content: ""; position: absolute; left: -1.25rem; top: 0; bottom: 0;
  width: 1px; background: var(--separator);
}
/* Dots: square, on the role lines only (work mode) – centred on the rail
   and on the entry's first text line. Not their own size: they are the
   site's square family – the same 0.375rem border-box marker with a 1px
   border that bullets and folder-chip squares use, filled like a checked
   box or an active chip, in the rail's muted shade. */
.timeline:not(.tl-heading-dots) .tl-title::after {
  content: ""; position: absolute; left: calc(-1.25rem - 2.5px); top: 0.5625rem;
  width: 0.375rem; height: 0.375rem;
  border: 1px solid var(--muted); background: var(--muted);
}

/* Work mode group start: the stroke begins at this org's first dot... */
.timeline:not(.tl-heading-dots) > :is(h2, h3) + .tl-title::before { top: 0.75rem; }
/* ...and ends at the last dot before the next heading (or the end). */
.timeline:not(.tl-heading-dots) .tl-title:has(+ :is(h2, h3))::before,
.timeline:not(.tl-heading-dots) .tl-title:last-child::before { bottom: auto; height: 0.75rem; }

/* Work mode sole role under a heading: start and end rules collide, so
   drop the segment entirely – dot only. */
.timeline:not(.tl-heading-dots) > :is(h2, h3) + .tl-title:has(+ :is(h2, h3))::before,
.timeline:not(.tl-heading-dots) > :is(h2, h3) + .tl-title:last-child::before { content: none; }

/* heading_dots={true} (Education, Volunteering on /cv/): one entry per
   org, so the dots ride the org headings instead and the rail runs
   continuously from the first heading's dot to the last one – the
   entries below the final dot carry no segment and the last heading
   clips its own segment at the dot. The dot sits on the heading's text
   line box: h3 is 1.125rem/1.5 (27px tall), so its centre is 0.84375rem
   below the text top, plus the heading's 1.75rem top padding on all but
   the first. */
.timeline.tl-heading-dots > :is(h2, h3)::before {
  content: ""; position: absolute; left: -1.25rem; top: 0; bottom: 0;
  width: 1px; background: var(--separator);
}
.timeline.tl-heading-dots > :is(h2, h3)::after {
  content: ""; position: absolute; left: calc(-1.25rem - 2.5px); top: 2.40625rem;
  width: 0.375rem; height: 0.375rem;
  border: 1px solid var(--muted); background: var(--muted);
}
.timeline.tl-heading-dots > :is(h2, h3):first-child::before { top: 0.84375rem; }
.timeline.tl-heading-dots > :is(h2, h3):first-child::after { top: 0.65625rem; }
/* Rail terminus: entries with no org heading after them sit below the
   last dot, so they draw no line... */
.timeline.tl-heading-dots .tl-title:not(:has(+ :is(h2, h3)))::before { content: none; }
/* ...and the section's last heading (last-of-type among the org names)
   stops its segment at its own dot. Never nest :has() inside :has() –
   Chromium rejects that, silently dropping the whole rule. */
.timeline.tl-heading-dots > :is(h2, h3):last-of-type:not(:first-child)::before {
  bottom: auto; height: 2.59375rem;
}
/* Sole org: the one heading is both first and last, zero-length rail. */
.timeline.tl-heading-dots > :is(h2, h3):first-child:last-of-type::before { content: none; }

/* The wrapper's final dot also gives back its trailing padding. */
.timeline .tl-title:last-child { padding-bottom: 0; }

.tl-title { padding: 0 0 1.25rem; }
.tl-title > :last-child { margin-bottom: 0; }
/* head, meta and mode are <p>s, so .prose p's 1.5rem margin beats a bare
   class; .timeline joining the selectors is what makes the tight
   role/period/mode rhythm actually stick. */
.timeline .tl-title-head {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
  margin: 0 0 0.25rem; color: var(--fg);
}
.tl-title-text { font-weight: 600; }
.timeline .tl-title-meta, .timeline .tl-title-mode { margin: 0 0 0.25rem; color: var(--muted); font-size: 0.875rem; }
.timeline .tl-title-mode { margin-bottom: 0.5rem; }
/* Pills inside a title head sit flush right; their look is the shared
   Pills module down by the buttons. */
.tl-title-head .pill { margin-left: auto; }

/* ---------- HTML niceties (raw elements usable in any .md) ---------- */
.prose kbd {
  display: inline-block; padding: 0.125rem 0.375rem; font-size: 0.8125rem;
  border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 0.25rem;
  background: var(--surface); color: var(--fg);
}
.prose mark { background: color-mix(in srgb, var(--yellow) 28%, transparent); color: inherit; }
.prose del { color: var(--muted); }
.prose ins { text-decoration: none; border-bottom: 1px solid var(--green); }
.prose dl { margin: 0 0 1.5rem; }
.prose dt { font-weight: 600; color: var(--fg); }
.prose dd { margin: 0 0 0.75rem 1rem; color: var(--muted); }
/* Accordions – the docs site's collapsible rows, 1:1: framed box, a
   min-height summary row with a + / − marker pinned right, surface hover,
   hairline under the closed row. The component form adds the docs' tinted
   body panel: <collapse title="…"> body </collapse> (components.html). */
.prose details, details.collapse { margin: 0 0 1.5rem; border: 1px solid var(--border); }
.prose summary, .collapse > summary {
  cursor: pointer; list-style: none; position: relative;
  display: flex; align-items: center; min-height: 4rem;
  padding: 0.875rem 2.5rem 0.875rem 0.75rem;
  font-weight: 600; color: var(--fg);
}
.prose summary::-webkit-details-marker, .collapse > summary::-webkit-details-marker { display: none; }
.prose summary::after, .collapse > summary::after {
  content: "+"; color: var(--muted); font-weight: 400;
  position: absolute; right: 0.75rem;
}
.prose details[open] > summary::after, .collapse[open] > summary::after { content: "-"; }
.prose summary:hover, .collapse > summary:hover { background: var(--surface); }
.prose details[open] > summary, .collapse[open] > summary { border-bottom: 1px solid var(--border); }
.prose details > :not(summary) { margin-inline: 1.25rem; }
.prose details[open] { padding-bottom: 0.5rem; }
.prose details p:last-child, .prose details ul:last-child { margin-bottom: 0; }
.collapse-body {
  border-top: 1px solid var(--border);
  background: color-mix(in srgb, var(--surface) 55%, transparent);
  padding: 1.5rem 1.25rem 2rem;
}
.collapse-body > :last-child { margin-bottom: 0; }
.prose figure { margin: 0 0 1.5rem; padding: 1rem; border: 1px solid var(--separator); }
.prose figcaption { margin-top: 0.5rem; font-size: 0.875rem; color: var(--muted); }
.prose figure pre:last-child, .prose figure p:last-child { margin-bottom: 0; }

/* ---------- Cards (docs-site .docs-card metric) ---------- */
.grid { display: grid; gap: 0.75rem; }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }

.card {
  display: block; color: var(--fg);
  border: 1px solid var(--border); border-radius: 0;
  padding: 1rem; background: transparent;
}
.card:hover { background: var(--surface); text-decoration: none; }
.card h3 { margin: 0.375rem 0; font-size: 0.875rem; line-height: 1.5; }
.card-meta {
  display: flex; justify-content: space-between; gap: 0.5rem;
  font-size: 0.75rem; color: var(--muted);
}
.card-sub { margin: 0 0 0.375rem; color: var(--muted); }
.card-desc { margin: 0 0 0.5rem; color: var(--muted); }

/* Cards written straight in content: <cards cols={2}> + <card title=""
   href=""> description </card> (components.html). Docs-site metric:
   .docs-card-group grid driven by --card-columns, cards = the same 1px
   frame as .card above, title as <strong>, muted description. */
.docs-card-group {
  display: grid; gap: 0.75rem; margin-bottom: 1.5rem;
  grid-template-columns: repeat(var(--card-columns, 2), minmax(0, 1fr));
}
/* !important mirrors the docs site: inside .prose a plain link colour rule
   would otherwise outweight this single class. */
.docs-card { display: block; color: var(--fg) !important; border: 1px solid var(--border); padding: 1rem; }
.docs-card:hover { background: var(--surface); text-decoration: none; }
.docs-card strong { display: block; margin-bottom: 0.375rem; }
.docs-card div { color: var(--muted); }
.docs-card div > :last-child { margin-bottom: 0; }
@media (width <= 48rem) { .docs-card-group { grid-template-columns: 1fr; } }

/* Tag chips – docs-site .api-meta metric (one size everywhere) */
.chips { display: inline-flex; flex-wrap: wrap; gap: 0.625rem; }
.chip {
  display: inline-flex; align-items: center; gap: 0.3rem;
  font-size: 0.75rem; color: var(--muted);
  border: 1px solid var(--border); border-radius: 0;
  padding: 0.375rem 0.625rem;
}
.chip:hover { color: var(--fg); border-color: var(--muted); text-decoration: none; }
.chip-count { color: var(--muted); font-size: 0.6875rem; }
/* Folder-style chips (kind = "folder" in tag_meta) – same look as the filter
   sidebar’s .tag-folder entries: a name plus a small square marker, in the
   same muted shade as the other chips. */
.chip-folder::before {
  content: ""; flex: 0 0 auto;
  width: 0.375rem; height: 0.375rem; border: 1px solid currentColor;
}
.tag-cloud { list-style: none; display: flex; flex-wrap: wrap; gap: 0.625rem; margin: 0 0 2rem; padding: 0; }

/* List rows (blog / projects index) */
.rows { border-top: 1px solid var(--separator); }
.row {
  display: grid; grid-template-columns: 8.5rem minmax(0, 1fr);
  gap: 1.5rem; padding: 1.25rem 0;
  border-bottom: 1px solid var(--separator);
}
.row-meta { font-size: 0.75rem; color: var(--muted); padding-top: 0.3rem; }
.row-body h2 { margin: 0; font-size: 1.125rem; line-height: 1.5; }
.row-body h2 a { color: var(--fg); }
.row-body h2 a:hover { color: var(--link); text-decoration: none; }
.row-sub { margin: 0.15rem 0 0; color: var(--muted); }
.row-desc { margin: 0.35rem 0 0.5rem; color: var(--muted); }
.row-foot { display: flex; flex-wrap: wrap; gap: 0.625rem; align-items: center; margin: 0.5rem 0 0; color: var(--muted); }
/* Rows that carry preview images: when ANY post in the section has one, the
   thumb column is reserved on every row so bodies stay aligned (pure CSS –
   Zola 0.23 dropped the Tera filters/namespaces that would do this in the
   template). */
.rows:has(.row-thumb) .row { grid-template-columns: 8.5rem 11rem minmax(0, 1fr); }
.rows:has(.row-thumb) .row > .row-body { grid-column: 3; }

/* ---------- Post preview images – set [extra] preview = "file" on a post ------
   Every slot is a fixed 16:9 box: Zola centre-crops the source to it, so any
   upload still lands consistently sized in rows, cards, hero and og:image. */
.card-thumb, .row-thumb { display: block; color: inherit; }
.card-thumb img, .row-thumb img, .post-hero img {
  display: block; width: 100%; height: auto; border-radius: 0;
  aspect-ratio: 16 / 9; object-fit: cover;
}
.card-thumb {
  margin: -1rem -1rem 0.75rem;      /* full-bleed inside the card padding */
  border-bottom: 1px solid var(--separator);
}
.row-thumb { align-self: start; border: 1px solid var(--border); }
.card-thumb:hover, .row-thumb:hover { text-decoration: none; }
.post-hero { margin: 0 0 2rem; }
.post-hero img { border: 1px solid var(--border); }
.post-hero figcaption { margin-top: 0.5rem; font-size: 0.75rem; color: var(--muted); }

/* ---------- Buttons – same 2.25rem control square as the header ---------- */
.btn {
  display: inline-flex; align-items: center; gap: 0.4rem;
  height: 2.25rem; padding: 0 0.875rem;
  border: 1px solid var(--border); border-radius: 0;
  background: transparent; color: var(--fg); cursor: pointer;
}
.btn:hover { border-color: var(--muted); text-decoration: none; }
.btn-primary {
  background: var(--accent); border-color: transparent;
  color: var(--on-accent); font-weight: 700;
}
.btn-primary:hover { background: color-mix(in srgb, var(--accent) 85%, var(--fg)); border-color: transparent; }
/* Buttons written in Markdown sit inside .prose, whose link colour would
   otherwise win on specificity – re-assert the button colours there. */
.prose a.btn, .prose a.btn:hover { color: var(--fg); }
.prose a.btn-primary, .prose a.btn-primary:hover { color: var(--on-accent); }

/* ---------- Pills – buttons' compact siblings ----------
   Right angles and the 1px hairline border come from the buttons; the
   fill and border colour follow the tone, mixed from the palette colour
   the same way the alert boxes' tinted borders/fills read. Only the
   type shrinks (they live inside meta rows, where the 2.25rem control
   square would never fit). Component: <pill text="" color=""> in
   templates/components.html. */
.pill {
  display: inline-block; padding: 0.25rem 0.5rem; font-size: 0.75rem; line-height: 1;
  border: 1px solid var(--border); border-radius: 0;
  background: transparent; color: var(--fg); white-space: nowrap;
}
.pill-green  { color: var(--green);  border-color: color-mix(in srgb, var(--green)  45%, transparent); background: color-mix(in srgb, var(--green)  12%, transparent); }
.pill-blue   { color: var(--blue);   border-color: color-mix(in srgb, var(--blue)   45%, transparent); background: color-mix(in srgb, var(--blue)   12%, transparent); }
.pill-yellow { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 45%, transparent); background: color-mix(in srgb, var(--yellow) 12%, transparent); }
.pill-red    { color: var(--red);    border-color: color-mix(in srgb, var(--red)    45%, transparent); background: color-mix(in srgb, var(--red)    12%, transparent); }

/* ---------- Home ---------- */
.hero-kicker { margin: 0 0 0.75rem; font-size: 0.8125rem; color: var(--muted); }
.hero-name {
  margin: 0; font-size: clamp(2.25rem, 6vw, 3.75rem);
  line-height: 1.1; letter-spacing: -0.03em; font-weight: 700;
}
.hero-name .accent { color: var(--accent); }
.hero-tagline { margin: 1rem 0 0; color: var(--muted); max-width: 38rem; }
.hero-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; margin-top: 1.5rem; }
.hero-social { display: inline-flex; gap: 0.5rem; margin-left: 0.25rem; }
.hero-social a { color: var(--muted); }
.hero-social a:hover { color: var(--fg); }

.home-sec { margin-top: 4rem; }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.sec-head h2 { margin: 0; font-size: 1.125rem; letter-spacing: -0.01em; }
.more { font-size: 0.75rem; color: var(--muted); }
.more:hover { color: var(--fg); }

.photo-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.75rem; }
.photo-strip img, .photo-grid img {
  display: block; width: 100%; border: 1px solid var(--border);
  border-radius: 0;
}
.photo-tile:hover img { border-color: var(--muted); }

/* ---------- Gallery ---------- */
.photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.25rem; }
.photo-tile { color: inherit; }
/* Tag filters: any list item can be filtered out (photo tiles, blog/project rows).
   Two attribute selectors outrank .row's display: grid, so [hidden] sticks. */
[data-tags][hidden] { display: none; }
.photo-tile:hover { text-decoration: none; }
.filter-empty { color: var(--muted); padding: 1.25rem 0; }
.photo-cap { display: flex; justify-content: space-between; gap: 0.75rem; margin-top: 0.4rem; font-size: 0.8125rem; }
.photo-main { margin: 0 0 1.5rem; }
.photo-main img { border: 1px solid var(--border); border-radius: 0; }
.photo-nav { display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem; font-size: 0.8125rem; }

/* ---------- Search ---------- */
/* Header trigger: see .search-trigger above (same square as .theme-toggle). */

/* The popup – docs-site .search-dialog metrics */
html.no-scroll body { overflow: hidden; }
.search-modal { position: fixed; inset: 0; z-index: 90; display: none; }
.search-modal.open { display: block; }
.search-backdrop { position: absolute; inset: 0; background: var(--backdrop); }
.search-panel {
  position: absolute; top: 10vh; left: 50%; transform: translateX(-50%);
  width: min(42rem, 100% - 2rem); max-height: min(36rem, 100vh - 4rem);
  max-height: min(36rem, 100dvh - 4rem);
  display: flex; flex-direction: column;
  background: color-mix(in srgb, var(--bg) 92%, transparent);
  backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
  border: 1px solid var(--border); border-radius: 0;
  color: var(--fg); padding: 0; overflow: hidden;
}
/* Input bar: docs-site .search-bar – ">" prompt, bare input, "Esc" button */
.search-bar {
  display: flex; align-items: center; gap: 0.75rem;
  padding: 0.75rem 1rem; border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}
.search-prompt { flex-shrink: 0; }
/* appearance:none strips iOS' native search-field chrome (inner rounding,
   inset padding); the transparent borderless docs-site look is ours.
   1:1 with the v2 docs site: its .search-bar input only ever gets
   outline:0 – the popup shows no focus ring anywhere, the caret is the
   keyboard cursor. */
.search-input {
  flex: 1; min-width: 0; background: transparent; border: 0; outline: 0;
  -webkit-appearance: none; appearance: none;
}
.search-input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.search-bar button, .search-x {
  color: var(--muted); background: transparent; border: 0; padding: 0; cursor: pointer;
}
.search-bar button:hover, .search-x:hover { color: var(--fg); }
/* Results: docs-site .search-results – one link per row (strong title +
   muted excerpt, the whole row clickable); matches in an excerpt are bold
   foreground marks, never highlight blocks. */
.search-status { margin: 0; color: var(--muted); padding: 0.75rem 1.25rem 0; }
.search-status:empty { display: none; }
.search-results { margin: 0; padding: 0.5rem; max-height: 28rem; overflow-y: auto; }
.search-results > p { margin: 0; padding: 0.75rem; color: var(--muted); }
.search-results a { display: block; padding: 0.75rem; color: var(--fg); }
.search-results a:hover { background: var(--surface-hover); text-decoration: none; }
/* Arrow-key cursor (docs-site behaviour): the selected row gets the same
   light-grey hover wash – no outline ring at all, since Chrome's default
   :focus-visible indicator would draw its blue border on the row. */
.search-results a:focus { outline: 0; background: var(--surface-hover); text-decoration: none; }
.search-results strong, .search-results span { display: block; }
.search-results strong { font-weight: 700; }
.search-results span { margin-top: 0.25rem; font-size: 0.8125rem; color: var(--muted); }
.search-results mark { background: transparent; color: var(--fg); font-weight: 700; }

/* Inline search page (/search/): same parts, no dialog chrome. Scoped to
   .search-page so these overrides never touch the popup panel, which is a
   .search-ui too. */
.search-page { max-width: var(--content-width); }
.search-page .search-bar { border: 1px solid var(--border); margin-bottom: 1rem; }
.search-page .search-status { padding: 0.5rem 0.5rem 0; }
.search-page .search-results { padding: 0; max-height: none; max-width: 44rem; }
.search-page .search-results a { padding: 0.75rem 0.5rem; }

/* ---------- Footer ---------- */
.site-footer { border-top: 1px solid var(--separator); }
.footer-inner {
  display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem;
  align-items: center; padding: 1.25rem var(--gutter);
  font-size: 0.8125rem; color: var(--muted);
}
.footer-inner p { margin: 0; }
.footer-links { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }
.footer-links a { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); }
.footer-links a:hover { color: var(--fg); text-decoration: none; }

/* ---------- Responsive (docs-site breakpoints: 64rem / 48rem) ---------- */
@media (max-width: 64rem) {
  .layout-list, .layout-doc { grid-template-columns: 14rem minmax(0, 1fr); }
  /* .toc is hidden below, so a ToC-only page is just the article. */
  .layout-toc { grid-template-columns: minmax(0, 1fr); }
  .toc { display: none; }
}
@media (max-width: 48rem) {
  :root { --gutter: 1rem; }
  .site-nav { display: none; }
  /* docs-site trick: collapsed trigger shows a ">" glyph instead of the label.
     The header gap is NOT tightened to 0.75rem as the old rule here did –
     the docs site keeps .header-inner at 1.5rem at every width, so the
     trigger → toggle spacing is identical on mobile and desktop. */
  .search-trigger { min-width: 2.25rem; padding: 0; justify-content: center; }
  .search-trigger .search-trigger-label, .search-trigger .kbd { display: none; }
  .search-trigger::before { content: ">"; }
  /* Nav links sit inline in the strip (no dropdown): one centered wrapping
     row. Same glass recipe as .site-header – see-through, not a solid block
     (deliberate deviation from the docs site, which uses a solid bg here).
     0.5rem + the links' own 0.25rem padding-block keeps the strip the exact
     height the closed "Menu" summary had (~2.9rem incl. border), so the
     scroll-margin math below still holds. */
  .mobile-navigation {
    display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
    gap: 0.25rem 1.25rem;
    position: sticky; top: var(--header-height); z-index: 10;
    padding: 0.5rem var(--gutter);
    background: color-mix(in srgb, var(--bg) 82%, transparent);
    backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
    border-bottom: 1px solid var(--separator);
  }
  .layout-list, .layout-doc { grid-template-columns: minmax(0, 1fr); }
  .sidebar {
    position: static; height: auto;
    border-right: 0; border-bottom: 1px solid var(--separator);
    padding: 1rem var(--gutter);
  }
  /* Article pages (layout-doc): the sibling-posts sidebar lands AFTER the
     article on phones – content first. List layouts (.layout-list) keep the
     tag filter bar on top, where it belongs. */
  .layout-doc .content { order: -1; }
  /* …and the sidebar separator moves to the top with it: the line sits
     between article and sibling posts instead of stacking against the
     footer's own border-top right below the sidebar. */
  .layout-doc .sidebar { border-top: 1px solid var(--separator); border-bottom: 0; }
  /* The list turns into wrapping rows here, so the vertical-list li spacing
     would push every item but the first 0.375rem down within a row and
     still glue wrapped rows together – row/column gap owns the spacing. */
  .side-list { display: flex; flex-wrap: wrap; gap: 0.375rem 1rem; }
  .side-list li + li { margin-top: 0; }
  .side-list a { flex-direction: row; align-items: baseline; gap: 0.4rem; }
  .side-title { display: none; }
  .content { padding: 2rem var(--gutter) 4rem; }
  .grid-3, .photo-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Mobile usability: anchor jumps must clear the sticky header AND the
     inline menu strip (~2.9rem) sitting below it at this width; the 16px
     social icons get a padded hit box. Header, menu, and search-bar paddings
     are left at the docs-site densities on purpose – the docs site keeps
     .search-bar button at padding:0 at every width, so "Esc" sits exactly
     1rem from the panel edge, mirroring the ">" prompt on the left. */
  .prose h1, .prose h2, .prose h3, .prose h4,
  .prose .footnote-reference, .prose .footnotes li {
    scroll-margin-top: calc(var(--header-height) + 3.75rem);
  }
  .hero-social a { padding: 0.5rem; }
  /* Long photo prev/next titles stack instead of squeezing each other. */
  .photo-nav { flex-wrap: wrap; row-gap: 0.5rem; }
}
@media (max-width: 40rem) {
  .grid-3, .photo-strip, .photo-grid { grid-template-columns: minmax(0, 1fr); }
  .row { grid-template-columns: minmax(0, 1fr); gap: 0.25rem; }
  .rows:has(.row-thumb) .row { grid-template-columns: minmax(0, 1fr); }
  .rows:has(.row-thumb) .row > .row-body { grid-column: auto; }
  .row-thumb { max-width: 24rem; margin-bottom: 0.5rem; }
  .row-meta { padding-top: 0; }
  .brand-logo { height: 1.25rem; }
}
