/* ==========================================================================
   Boundless
   --------------------------------------------------------------------------
   The palette is sampled from the app icon: a glowing infinity mark over a
   diagonal indigo, violet, blue, teal wash. Every gradient on this site
   traces back to those four stops so the site and the app read as one thing.

   Dark is the default because the app is a reader and readers live in dark
   rooms. Light is a full peer, not an afterthought.
   ========================================================================== */

/* Self-hosted, not linked from Google's own CDN: this site's CSP pins
   font-src to 'self', so this typeface has to be served from here or not
   exist at all. One file, a variable font covering the full weight axis
   (confirmed by Google's own CSS serving the identical file for every
   weight from 400 through 800, just re-labelled), a single @font-face
   with a weight range registers all of it under one family name, rather
   than needing a separate file and rule per weight actually used. */
@font-face {
  font-family: 'Boundless Display';
  /* ?v=1: server.js's static-asset middleware only sends the long-lived
     immutable cache header to requests carrying a ?v= query string, so this
     rarely-changing file needs one by hand. Whoever replaces the actual
     .woff2 file in the future has to bump this number too, or returning
     visitors keep getting served the old cached font for up to a year. */
  src: url('/fonts/BricolageGrotesque-Variable.woff2?v=1') format('woff2-variations');
  font-weight: 300 800;
  font-style: normal;
  font-display: swap;
}

:root {
  /* The Boundless stops, held on aliases so the four values are written down
     exactly once. Two things need to read them: the site default immediately
     below, and a profile that has chosen the Boundless accent, which needs
     its own [data-accent='boundless'] row further down for the same reason
     the other nine palettes need theirs. Nothing else changes: with
     data-palette unset, --indigo and friends resolve to precisely what they
     always did. */
  --boundless-indigo: #241d57;
  --boundless-violet: #4b3aed;
  --boundless-blue: #3d6fe0;
  --boundless-teal: #17aacb;

  --indigo: var(--boundless-indigo);
  --violet: var(--boundless-violet);
  --blue: var(--boundless-blue);
  --teal: var(--boundless-teal);

  --brand-gradient: linear-gradient(135deg, var(--indigo), var(--violet), var(--blue), var(--teal));
  --accent-gradient: linear-gradient(135deg, var(--violet), var(--teal));

  --radius-sm: 8px;
  --radius: 14px;
  --radius-lg: 20px;
  --radius-xl: 28px;

  --shell: 1200px;
  --reading: 720px;

  /* The whole site's body/UI type, not just the wordmark: one bespoke
     family (see the @font-face above) instead of the system UI stack
     everywhere the wordmark doesn't reach, so nav, buttons, forms, and
     prose read as the same considered typeface the hero introduces rather
     than falling back to generic system chrome around it. Self-hosted,
     variable weight (300-800 in the one file), falls back to the same
     system-font stack if it ever fails to load. */
  --font: 'Boundless Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
  /* Display type for headlines, kept separate from body copy on purpose --
     a serif/sans pairing is what makes a page read as considered rather
     than templated. `ui-serif` resolves to New York on every platform
     Boundless actually ships on, so this costs nothing: no font file, no
     request, no CSP exception, just a system font stack that happens to be
     a genuine match for an Apple-only reader app. */
  --font-display: ui-serif, 'New York', 'Iowan Old Style', Georgia, 'Times New Roman', serif;
  /* Alias kept for the wordmark rules that already reference it by this
     name. Same family as --font now, not a second typeface. */
  --font-modern: var(--font);
  /* The exact stops and angle of #boundless-stroke (see head.ejs), as a
     reusable CSS gradient, so the wordmark's fill and the mark's own
     stroke are provably the same gradient, not two hand-matched ones that
     can drift apart later. */
  --mark-gradient: linear-gradient(135deg, var(--violet), var(--blue), var(--teal));

  /* The hero fabric shader's own tint, kept on its own custom properties
     instead of reading --indigo/--violet/--blue/--teal directly, so this
     one layer can be retinted, per theme or otherwise, by editing only the
     eight lines below, without ever touching the JS that reads them or the
     GLSL that consumes them. The defaults still lean on the brand palette
     (mixed toward black, white, or each other) so the shader matches the
     site out of the box, but nothing here requires that to stay true. */
  --fabric-color-deep: color-mix(in srgb, color-mix(in srgb, var(--indigo), var(--blue)) 30%, black);
  --fabric-color-mid: color-mix(in srgb, color-mix(in srgb, var(--blue), var(--teal)) 45%, black);
  /* The white-blend percentages here are a floor as much as a tint: some
     palettes (Mono most of all, whose own --teal is literal white and
     --blue a mid grey) already sit close to white before this mix ever
     touches them, and the previous, heavier white percentages pushed those
     particular stops close enough to the page background that the hero
     wordmark sitting on top of them lost most of its contrast. Keeping
     more of the underlying blue/teal mix and less added white holds every
     palette's own strength (still confirmed against Boundless and Void, the
     two that were already correct) while giving Mono specifically enough
     room to stay legible instead of mixing all the way to near-white. */
  --fabric-color-light: color-mix(in srgb, color-mix(in srgb, var(--blue), var(--teal)) 80%, white);
  --fabric-color-highlight: color-mix(in srgb, var(--blue) 12%, white);
  --fabric-speed: 1;
  --fabric-sheen: 1;
  --fabric-ao-strength: 0.55;
  --fabric-ridge-brighten: 0.2;
  /* Strength of the shader's slow exposure "breath", a very slow rise and
     fall in overall light of about eight percent at 1. Read by
     initFabricShader the same way --fabric-sheen is; 0 holds the exposure
     perfectly steady. */
  --fabric-breathe: 1;
  /* Opacity of the acrylic pane's own grain in the hero. A flat 0.05 read
     fine on the colourful palettes but all but vanished on the two that
     have no hue to carry texture, so Mono and Void each lift it below. */
  --hero-grain: 0.05;
  /* The hero word grid's two inks (see initWordGrid in site.js), read
     through the same live-colour probe the fabric shader uses for its own
     tints, so they follow the palette instead of the fixed grey-blue the
     grid used to carry. The ordinary letters are a dim, cool ink derived
     from the palette's blue over the theme's soft text; the seeded words
     glow in near-white leaning toward --teal. Light theme flips both
     toward the palette's indigo, since the grid multiplies there rather
     than screens. */
  --words-ink: color-mix(in srgb, var(--blue) 30%, var(--text-soft));
  --words-glow: color-mix(in srgb, var(--teal) 25%, white);

  /* The type scale. Every font-size on the site resolves to one of these
     steps so that neighbouring elements land on the same few sizes rather
     than on whichever rem value each rule happened to be written with.
     The steps are, at a phone and at a desktop width:
       --step--2  11.6 to 12.2px   caps labels, pills, chips, tags, th
       --step--1  13.3 to 14.4px   meta, hints, captions, rail and toc links
       --step-0s  14.9 to 15.4px   small body: card copy, buttons, inputs, notes
       --step-0   16   to 17px     body and prose
       --step-0l  18.1 to 19.2px   sans component titles (card h3, panel h2)
       --step-1   19.2 to 22.4px   ledes and serif card titles
       --step-2   24   to 32px     page h1s, prose h2, compact section heads
       --step-3   30   to 45px     section and doc heads
       --step-4   37   to 56px     reserved for display
     The two half steps around the body size exist because the site's
     components genuinely need them: card copy wants to sit just under the
     prose, and a card title just over it, without either jumping a whole
     step. The wordmarks, the 404 code and the auth poster keep their own
     clamps; they are display pieces, not running text. */
  --step--2: clamp(0.72rem, 0.7rem + 0.1vw, 0.76rem);
  --step--1: clamp(0.83rem, 0.8rem + 0.15vw, 0.9rem);
  --step-0s: clamp(0.92rem, 0.88rem + 0.2vw, 0.96rem);
  --step-0: clamp(1rem, 0.96rem + 0.2vw, 1.06rem);
  --step-0l: clamp(1.12rem, 1.06rem + 0.3vw, 1.2rem);
  --step-1: clamp(1.2rem, 1.1rem + 0.5vw, 1.4rem);
  --step-2: clamp(1.5rem, 1.3rem + 1vw, 2rem);
  --step-3: clamp(1.9rem, 1.5rem + 2vw, 2.8rem);
  --step-4: clamp(2.3rem, 1.7rem + 2.6vw, 3.5rem);
  /* One tracking for every uppercase label (eyebrows, rail headings, table
     headers, kind chips). Caps at --step--2 need air between the letters
     to read; 0.11em is the value most of them had already settled on. */
  --track-caps: 0.11em;

  --ease: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);

  color-scheme: dark;
}

/* Dark tokens are the default so a page paints correctly before any script
   has run. Light overrides them wholesale on the root element. */
:root {
  --bg: #0a0b11;
  --bg-soft: #0f111a;
  --surface: rgba(255, 255, 255, 0.035);
  --surface-solid: #14161f;
  --surface-raised: #1a1d29;
  --border: rgba(255, 255, 255, 0.09);
  --border-strong: rgba(255, 255, 255, 0.16);
  --text: #e9ebf5;
  --text-soft: #b6bccf;
  --text-muted: #868da5;
  /* Was a flat hardcoded hex, so every non-default palette left inline text
     links stuck on Boundless's own blue-violet no matter what was actually
     selected. color-mix reads the live --violet role stop instead, the same
     derivation --glow and --mark just below already use, so a link retints
     the instant a palette changes with no per-palette override needed here. */
  --link: color-mix(in srgb, var(--violet) 70%, white);
  --shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.8);
  /* Foreground colour for text/icons that sit directly on the fixed brand
     gradient (.site-header, .site-footer) or the accent gradient's small
     "keycap" badges (.card-icon, .avatar, .theme-toggle-thumb, .tag.is-active,
     .period-tab.is-active). Surfaces that are deliberately NOT themed, so
     they can't fall back to --text/--text-soft/--text-muted the way
     everything else on the page does. White reads fine against every one of
     these ten accent palettes' gradients once the dark scrims below
     (.site-header::before, .site-footer, and the badge backgrounds
     themselves) are applied. Verified per palette against the gradient
     colour actually sampled at each real element's on-screen position, not
     just the raw stop colours, see the contrast numbers in the git history
     for this change. No palette needed a darker override once the scrims
     were tuned, so these three stay one flat definition with no
     :root[data-palette='...'] overrides. If a future palette is added,
     re-run that same per-position check before assuming white still holds. */
  --on-brand: #fff;
  --on-brand-soft: rgba(255, 255, 255, 0.78);
  --on-brand-muted: rgba(255, 255, 255, 0.6);
  /* Both of these were a hardcoded rgba(75, 58, 237, ...), the hex value of
     the default Boundless palette's own --violet, baked in before this site
     had a colour-theme picker. color-mix keeps the same alpha blend but
     reads the live custom property, so a chosen palette's own violet-role
     stop shows up in the primary button's glow and in text selection
     instead of every palette still glowing Boundless-blue-violet regardless
     of what is actually selected. */
  --glow: 0 0 40px -8px color-mix(in srgb, var(--violet) 55%, transparent);
  --code-bg: #0d0f18;
  --mark: color-mix(in srgb, var(--violet) 28%, transparent);
}

:root[data-theme='light'] {
  color-scheme: light;
  /* No --on-brand override here: --indigo/--violet/--blue/--teal (and so
     --brand-gradient/--accent-gradient) are set once on the bare :root above
     and are never redefined per theme, only per data-palette. Confirmed by
     grep, light theme only re-tints --fabric-color-*, --link, --shadow and
     --glow. So the header/footer gradient and the badge gradients render
     byte-identical in light and dark theme, and the same --on-brand values
     already cover both. */
  --bg: #f6f7fc;
  --bg-soft: #ffffff;
  --surface: rgba(255, 255, 255, 0.75);
  --surface-solid: #ffffff;
  --surface-raised: #ffffff;
  --border: rgba(20, 22, 40, 0.1);
  --border-strong: rgba(20, 22, 40, 0.18);
  --text: #14161f;
  --text-soft: #414659;
  --text-muted: #666d84;
  /* Same derivation as the dark default above, mixed toward black instead
     of white since this sits on a light page: still the live --violet role
     stop, not a fixed hex, so light-theme links retint per palette too. */
  --link: color-mix(in srgb, var(--violet) 85%, black);
  --shadow: 0 18px 40px -22px rgba(24, 26, 48, 0.4);
  --glow: 0 0 40px -14px color-mix(in srgb, var(--violet) 40%, transparent);
  --code-bg: #f2f3fa;
  --mark: color-mix(in srgb, var(--violet) 16%, transparent);

  /* A touch brighter than the dark defaults above, the same restraint
     already applied to .hero-atmosphere-base and .hero-atmosphere-sheen
     elsewhere in this file, but nowhere near as diluted as a first pass
     here tried: pushing the mid/light stops most of the way to white left
     the cloth reading as a flat pale wash with barely any colour in it,
     confirmed by screenshot, so this keeps most of the same brand-colour
     strength as the dark tokens and only lifts the very deepest shadow. */
  --fabric-color-deep: color-mix(in srgb, color-mix(in srgb, var(--indigo), var(--blue)) 34%, black);
  --fabric-color-mid: color-mix(in srgb, color-mix(in srgb, var(--blue), var(--teal)) 60%, black);
  /* Same floor as the dark tokens above, tuned down from a heavier white
     percentage for the same reason: keeps Mono's already-light blue/teal
     stops from mixing all the way to near-white here too. */
  --fabric-color-light: color-mix(in srgb, color-mix(in srgb, var(--blue), var(--teal)) 82%, white);
  --fabric-color-highlight: color-mix(in srgb, var(--blue) 10%, white);
  /* Multiply-blended over a near-white fabric here, so the grid needs dark
     ink to show at all: indigo-derived rather than the dark theme's pale
     screen inks above. */
  --words-ink: color-mix(in srgb, var(--indigo) 60%, var(--text-soft));
  --words-glow: color-mix(in srgb, var(--indigo) 80%, var(--violet));
}

/* The site's other nine accent themes. There is no visible control for
   these left in the footer any more, the picker now lives as a hidden
   easter egg behind the home page's Appearance swatch-row (see home.ejs
   and site.js), found by clicking rather than labelled as a control, but
   the palettes themselves and this whole mechanism are unchanged either
   way. Each block only ever touches the same four source-of-truth stops
   the bare :root default above uses, --indigo, --violet, --blue, --teal,
   so every gradient built from them, --brand-gradient, --accent-gradient,
   --mark-gradient, the fabric shader's --fabric-color-* mixes, the lot,
   retints for free with no further overrides needed here. Boundless
   itself gets no block: it is already those bare :root values, so leaving
   data-palette unset is what "Boundless" means. Void is the one exception
   to the family's usual dark-to-light climb, staying dark at every stop
   on purpose, see the swatch-row comment in home.ejs for why. */
/* Each line carries a second selector, [data-accent='...'], which is how a
   public profile wears a palette of its owner's choosing (see the profile
   block near the bottom of this file). The values are untouched: only the
   selector lists are longer, so there is still exactly one copy of every hex
   in this file. data-accent sits on the profile card, a descendant of <html>,
   so it always wins for its own subtree whatever data-palette says, with no
   specificity fight and no !important. */
:root[data-palette='nova'], [data-accent='nova'] { --indigo: #3A1808; --violet: #E8732A; --blue: #F2954A; --teal: #FFC66D; }
:root[data-palette='ink'], [data-accent='ink'] { --indigo: #0D0D12; --violet: #5C6472; --blue: #8B93A1; --teal: #D3D9E1; }
:root[data-palette='mono'], [data-accent='mono'] { --indigo: #000000; --violet: #5A5A5C; --blue: #9B9B9E; --teal: #FFFFFF; }
:root[data-palette='reverse'], [data-accent='reverse'] { --indigo: #241D57; --violet: #5B4FE0; --blue: #9FB8E8; --teal: #F5F8FC; }
:root[data-palette='blossom'], [data-accent='blossom'] { --indigo: #4A0E2E; --violet: #E0447B; --blue: #F2789F; --teal: #FFD6E8; }
:root[data-palette='ember'], [data-accent='ember'] { --indigo: #1A0800; --violet: #D62828; --blue: #FF6B1A; --teal: #FFC93F; }
:root[data-palette='mint'], [data-accent='mint'] { --indigo: #052E26; --violet: #12A37E; --blue: #4DDBAB; --teal: #CFF7E7; }
:root[data-palette='royal'], [data-accent='royal'] { --indigo: #1F0A33; --violet: #8A3FE8; --blue: #C08AF2; --teal: #F0C674; }
:root[data-palette='void'], [data-accent='void'] { --indigo: #000000; --violet: #0D0B14; --blue: #241F33; --teal: #4A3F66; }

/* Boundless needs a row here for the same reason the other nine do, even
   though it is also the bare :root default: an accent is the profile owner's
   choice and must not change with the palette the *visitor* happens to have
   picked. A profile is a page you hand out; it should not have a different
   identity in somebody else's browser. It reads the --boundless-* aliases on
   :root, so this adds no eleventh copy of those four values. */
[data-accent='boundless'] {
  --indigo: var(--boundless-indigo);
  --violet: var(--boundless-violet);
  --blue: var(--boundless-blue);
  --teal: var(--boundless-teal);
}
/* The two palettes with no hue to speak of lean on the hero's grain
   instead, see --hero-grain on the bare :root above. */
:root[data-palette='mono'] { --hero-grain: 0.09; }
:root[data-palette='void'] { --hero-grain: 0.08; }

/* ==========================================================================
   Base
   ========================================================================== */

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

html {
  -webkit-text-size-adjust: 100%;
  /* No scroll-behavior: smooth here any more. Set on html it also applied
     to a deep link arriving from another page, which glided thousands of
     pixels down a doc on load instead of opening on the linked section;
     site.js now scrolls in-page clicks (TOC, heading anchors, the chapter
     rail) smoothly itself and leaves everything else instant. */
  /* Anchor links land under the sticky header without this offset: the
     header is 62px and the floating pill sits 12px below the top edge, so
     84px clears the pill with a little air. This is the one and only
     offset, the .prose headings used to add a scroll-margin-top of their
     own on top of it, and the two stacked to ~180px of dead space. */
  scroll-padding-top: 84px;
}

body {
  margin: 0;
  font-family: var(--font);
  font-size: var(--step-0);
  line-height: 1.65;
  color: var(--text);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
  position: relative;
}

/* A faint grain over the whole page, the difference between a flat colour
   fill and a surface with texture, at an opacity too low to notice except as
   "this feels a little more physical than most sites." Drawn as an inline
   SVG filter, not an image asset, so it costs one style rule and no request
   -- consistent with this site shipping no third-party fonts or scripts. */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 2147483647;
  pointer-events: none;
  opacity: 0.035;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
:root[data-theme='light'] body::before { opacity: 0.02; mix-blend-mode: multiply; }

h1, h2, h3, h4, h5, h6 {
  margin: 0 0 0.5em;
  line-height: 1.15;
  letter-spacing: -0.025em;
  font-weight: 700;
  text-wrap: balance;
}

p { margin: 0 0 1.1em; }
p:last-child { margin-bottom: 0; }

a {
  color: var(--link);
  text-decoration-color: color-mix(in srgb, var(--link) 35%, transparent);
  text-underline-offset: 3px;
  transition: color 0.15s var(--ease), text-decoration-color 0.15s var(--ease);
}
a:hover { text-decoration-color: currentColor; }

img, svg, video { max-width: 100%; height: auto; display: block; }

hr {
  border: 0;
  height: 1px;
  background: var(--border);
  margin: 2.5rem 0;
}

::selection { background: var(--mark); color: var(--text); }

:focus-visible {
  outline: 2px solid var(--violet);
  outline-offset: 3px;
}

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  top: -100px;
  left: 12px;
  z-index: 200;
  background: var(--surface-raised);
  color: var(--text);
  padding: 12px 18px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-strong);
}
.skip-link:focus { top: 12px; }

.shell {
  width: 100%;
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: clamp(18px, 4vw, 32px);
}

/* ==========================================================================
   Brand marks
   ========================================================================== */

/* A default width matters: an SVG with no intrinsic size stretches to fill
   whatever container it lands in, which is how a 30px logo becomes a
   full-width one. Every context that needs a different size overrides it. */
.mark { display: block; width: 32px; aspect-ratio: 1; }

.mark-bg { fill: url(#boundless-stroke); }
.mark-path {
  fill: none;
  stroke: #fff;
  stroke-width: 4.6;
  stroke-linecap: round;
}
/* The #boundless-stroke gradient's own stops (see head.ejs), driving the
   icon's background fill everywhere the mark appears. stop-color is a real
   CSS property on SVG <stop> elements, and custom properties cascade into
   inline SVG the same as anywhere else in the document, so these four
   rules are what makes the icon retint the instant --indigo/--violet/
   --blue/--teal change, whether from the palette switcher or the
   data-palette attribute it sets, with no JS of its own needed here. */
.stop-indigo { stop-color: var(--indigo); }
.stop-violet { stop-color: var(--violet); }
.stop-blue { stop-color: var(--blue); }
.stop-teal { stop-color: var(--teal); }

.wordmark {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
  font-family: var(--font-modern);
  font-weight: 700;
  font-size: var(--step-0s);
  letter-spacing: -0.01em;
  text-decoration: none;
}
.wordmark span {
  white-space: nowrap;
  /* Plain white, not a gradient clip: the header itself is the brand
     gradient now (see .site-header below), and gradient text over its own
     gradient background is the same near-vanishing problem the footer's
     sign-off had. Fixed the same way, by making the text the one flat
     colour that reads against all four stops at once. */
  color: #fff;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.35);
}
/* A thin ring keeps the mark a distinct badge instead of blending into the
   identical gradient now sitting behind it, the same fix the footer's
   much larger repeat of this same mark needed. */
.wordmark .mark {
  width: 30px;
  border-radius: 22%;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.35), 0 2px 8px rgba(0, 0, 0, 0.3);
}

/* As the header gets tight. Before it collapses all the way to the
   hamburger at 860px, the wordmark sheds its text and keeps just the
   mark, not the other way round. The infinity glyph alone is the actual
   brand icon and stays recognisable on its own; the word "Boundless"
   next to it is the part that can go first. Width-based, not page-based,
   so it is identical on every page rather than depending on which one
   you happen to be looking at.
   1120px, not a rounder number: the .search box reappears at 1000px (see
   its own min-width comment) and needs everything up to about 1113px to
   still be tight enough that, without this, the header actions would get
   pushed a few pixels past the header's own right edge. */
@media (max-width: 1120px) {
  .wordmark span { display: none; }
}

/* ==========================================================================
   Header
   ========================================================================== */

/* The header sits on the same four-stop gradient as the footer and the app
   icon itself, full strength rather than a spilled tint, so, like the
   footer, everything inside it (wordmark, nav, search, buttons) is styled
   against a fixed, saturated backdrop rather than the theme's ordinary
   tokens, which is why so many rules below are scoped under .site-header
   specifically instead of just inheriting the page's light/dark colours. */
/* Flat and edge-to-edge at rest, then on the very first pixel of scroll
   (see .is-stuck in site.js. window.scrollY > 8) it lifts into an inset,
   rounded, floating card. The gradient itself lives on a ::before, not on
   .site-header directly: a sticky element's own margin does not reliably
   open a gap above it (confirmed against the real rendered box. Margin-top
   was accepted but the element's top edge stayed pinned to the viewport
   edge regardless, which read as the top corners rounding against nothing,
   not floating), while an absolutely-positioned layer inset from 0 to a
   real gap on all three open sides has no such ambiguity. .site-header
   itself stays a plain, transparent, always-full-bleed sticky shell; only
   its ::before's inset/radius/shadow transitions. */
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  isolation: isolate;
}
.site-header::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  /* A flat dark scrim under the brand gradient, not just the gradient on its
     own, with 10 accent palettes climbing to very different lightest
     stops (mono's --teal is literal white; Boundless's own teal alone gave
     white text only ~2.75:1), no fixed white/dark foreground could clear
     AA against all of them at full gradient strength. .48 was landed on by
     computing real WCAG contrast for --on-brand white text at each of these
     elements' actual on-screen positions along the gradient (not the bare
     0%/100% stops) for every one of the 10 data-palette values, the
     worst case (Mint, at the "Sign in" button's position) still clears
     4.5:1 with margin at this strength, and it's the lightest scrim that
     does. Also why no per-palette --on-brand override was needed after all:
     the scrim alone, tuned against the real render, was enough. */
  background: linear-gradient(rgba(0, 0, 0, 0.48), rgba(0, 0, 0, 0.48)), var(--brand-gradient);
  border-radius: 0;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
  transition: inset 0.45s var(--ease-out), border-radius 0.4s var(--ease-out), box-shadow 0.4s var(--ease);
}
.site-header.is-stuck::before {
  inset: 12px clamp(12px, 3vw, 28px) 0;
  border-radius: var(--radius-lg);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.14),
    0 24px 48px -24px rgba(0, 0, 0, 0.6),
    0 4px 16px -4px rgba(0, 0, 0, 0.35);
}

.header-inner {
  position: relative;
  display: flex;
  align-items: center;
  gap: 20px;
  height: 62px;
  transition: padding-inline 0.45s var(--ease-out), padding-top 0.45s var(--ease-out);
}
/* The pill (.site-header.is-stuck::before) is inset 12px from the top but
   flush (0) at the bottom, since it floats away from the viewport's top
   edge but stays flush with the content that starts right below it, so
   its own visual centre sits 6px lower than .header-inner's full 62px box.
   align-items:center alone would keep centring on that full box and read
   as everything sitting too high once the pill no longer fills it evenly;
   matching the pill's own top inset here shrinks the flex box's content
   area by the same 12px from the top, so centring happens against the
   pill's actual band instead of the taller box around it. */
.site-header.is-stuck .header-inner {
  padding-top: 12px;
}
/* .header-inner's own .shell padding (clamp 18-32px) and the pill's own
   inset (see .site-header.is-stuck::before) are measured from two
   different edges, the header's full-bleed box vs the now-inset pill --
   and left just ~4px between the pill's rounded corner and whatever sits
   closest to it. That was symmetric on paper (identical clamp on both
   sides) but not in practice: the wide, equally-rounded "Create account"
   pill crowds its corner far more visibly at 4px than the small square
   mark does at its corner, which is what actually read as lopsided.
   Widening the content's own inset specifically while stuck, rather than
   just shrinking the pill further, is what gives every corner a real,
   consistent gap regardless of what happens to be sitting in it. */
.site-header.is-stuck .header-inner {
  padding-inline: clamp(28px, 6vw, 48px);
}

/* The pill carried across a page navigation. theme.js puts .header-carry
   on <html> before first paint when the page being left had its header
   in pill form (site.js writes that flag on pageswap), so the new page
   paints with the pill already formed rather than hard-cutting to the
   flat bar while everything else crossfades. Every value here mirrors
   .is-stuck exactly; the only difference is transition: none, so nothing
   animates into this state, it is simply the state the page opens in.
   site.js removes the class once the transition has finished, at which
   point the ordinary .site-header::before transition takes over and the
   pill relaxes into the bar if the new page is at the top. */
.header-carry .site-header::before {
  inset: 12px clamp(12px, 3vw, 28px) 0;
  border-radius: var(--radius-lg);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.14),
    0 24px 48px -24px rgba(0, 0, 0, 0.6),
    0 4px 16px -4px rgba(0, 0, 0, 0.35);
  transition: none;
}
.header-carry .header-inner {
  padding-top: 12px;
  padding-inline: clamp(28px, 6vw, 48px);
  transition: none;
}

/* Reading progress along the pill's bottom edge, on a single doc or blog
   post only (head.ejs decides). The pill is inset from the viewport's
   sides and rounded by --radius-lg at its bottom corners, so the line is
   inset by the same side gap plus the radius: it runs between the two
   corner curves, never through them. Same violet-to-teal ramp as
   .rank-progress-bar, the site's existing progress idiom, derived from
   the palette so it retints with everything else. Hidden while the header
   is the flat bar (nothing has been read yet at scrollY 0 anyway) and
   only shown once the pill has formed. */
.read-progress {
  position: absolute;
  left: calc(clamp(12px, 3vw, 28px) + var(--radius-lg));
  right: calc(clamp(12px, 3vw, 28px) + var(--radius-lg));
  bottom: 0;
  height: 2px;
  border-radius: 2px;
  pointer-events: none;
  transform-origin: left center;
  transform: scaleX(var(--progress, 0));
  background: linear-gradient(90deg, color-mix(in srgb, var(--violet) 90%, transparent), color-mix(in srgb, var(--teal) 90%, transparent));
  opacity: 0;
  transition: opacity 0.3s var(--ease);
}
.site-header.is-stuck .read-progress,
.header-carry .site-header .read-progress { opacity: 1; }
/* Scroll-driven where the browser can do it without script: the line's
   scaleX tracks the root scroller from 0 at the top of the page to 1 at
   the bottom. animation-timeline has to come after the animation
   shorthand, which would otherwise reset it. site.js only sets --progress
   in browsers that lack this. */
@supports (animation-timeline: scroll()) {
  .read-progress {
    transform: scaleX(0);
    animation: read-progress-grow linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes read-progress-grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

.site-nav {
  display: flex;
  align-items: center;
  gap: 4px;
  margin-left: 8px;
}

/* White rather than the theme's --text-soft/--text tokens: .site-nav lives
   inside .site-header at every width, whether laid out inline in the
   gradient bar or, below 860px, as the dropdown panel further down this
   file, and that panel is a solid brand-indigo surface for the same
   reason, so plain white text is correct in both of its shapes. */
.site-nav a {
  position: relative;
  overflow: hidden;
  display: inline-block;
  height: 38px;
  color: var(--on-brand-soft);
  text-decoration: none;
  font-size: var(--step-0s);
  font-weight: 500;
  border-radius: var(--radius-sm);
  transition: color 0.15s var(--ease), background 0.15s var(--ease);
}
.site-nav a:hover { color: var(--on-brand); }
.site-nav a[aria-current='page'] { color: var(--on-brand); background: rgba(255, 255, 255, 0.14); }
/* Solid white, not the usual --accent-gradient ramp: a colour-on-colour
   underline would only read against some of the four stops behind it,
   not all of them. */
.site-nav a::after {
  content: '';
  position: absolute;
  left: 12px;
  right: 12px;
  bottom: 2px;
  height: 2px;
  border-radius: 2px;
  background: #fff;
  transform: scaleX(0);
  transition: transform 0.25s var(--ease-out);
}
.site-nav a:focus-visible::after,
.site-nav a[aria-current='page']::after {
  transform: scaleX(1);
}

/* The label flips up and out on hover, a second copy of the same text
   sliding up from underneath to land in its place. Two real, identically-
   sized rows rather than one row plus a %-based ::after clone: a percentage
   transform is relative to the row's own content height, which does not
   equal the link's height once padding enters into it, and leaves a sliver
   of the old row still in view (confirmed while building this, a
   translateY(-100%) landed a few pixels short every time). Two rows of a
   fixed height, shifted by that same fixed height, has no such gap since
   both numbers are literally the same value. */
.nav-flip {
  display: block;
  transition: transform 0.4s var(--ease-out);
}
.nav-flip-row {
  display: flex;
  align-items: center;
  height: 38px;
  padding: 0 12px;
}
.site-nav a:hover .nav-flip,
.site-nav a:focus-visible .nav-flip {
  transform: translateY(-38px);
}
@media (prefers-reduced-motion: reduce) {
  .nav-flip { transition: none; }
}

/* Sign in lives in the header at every width where the header has room for
   it. This copy inside the nav panel only exists for the narrow widths
   where the header button is hidden, so it stays out of the way until
   then rather than appearing twice. */
.site-nav .nav-account { display: none; }

.header-actions {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Fixed white-based tones rather than the --border/--surface/--text-soft
   theme tokens: both .icon-button (the hamburger) and .theme-toggle below
   live only inside .site-header, which no longer follows the page theme
   itself, so neither should the controls sitting on it. */
.icon-button {
  display: inline-grid;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.22);
  background: rgba(255, 255, 255, 0.12);
  color: var(--on-brand-soft);
  cursor: pointer;
  padding: 0;
  transition: color 0.15s var(--ease), border-color 0.15s var(--ease), transform 0.15s var(--ease);
}
.icon-button:hover { color: var(--on-brand); border-color: rgba(255, 255, 255, 0.4); }
.icon-button:active { transform: scale(0.94); }
.icon-button svg { width: 17px; height: 17px; }

/* A real glass switch, not an icon button: a frosted pill track with a
   gradient thumb that physically slides, matching the toggle pattern used
   for the theme control elsewhere in this kind of interface, the button
   shows the theme you would switch TO, on the thumb, not the current one. */
.theme-toggle {
  position: relative;
  display: block;
  width: 52px;
  height: 30px;
  padding: 0;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.14);
  border: 1px solid rgba(255, 255, 255, 0.28);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 -1px 1px rgba(0, 0, 0, 0.12);
  cursor: pointer;
  transition: background 0.15s var(--ease), border-color 0.15s var(--ease);
}
.theme-toggle-thumb {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  /* Same two-stop --accent-gradient every other "keycap" badge on the page
     uses (.card-icon, .avatar, .tag.is-active, .period-tab.is-active), so
     it needs the same scrim those get below, a centred icon/glyph sits at
     the gradient's exact midpoint (CSS gradients guarantee the box centre
     is the 50% stop regardless of box size or aspect ratio), and at that
     midpoint the raw violet/teal mix fails 3:1 for white in 8 of the 10
     palettes (as low as 1.9:1 for Mint). This accent gradient is far more
     saturated at its middle than the four-stop brand gradient above ever
     gets, so it needed its own, separately-tuned scrim rather than
     inheriting the header/footer's .48. */
  background: linear-gradient(rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.28)), var(--accent-gradient);
  color: var(--on-brand);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
  transition: transform 0.3s var(--ease-out);
}
:root[data-theme='light'] .theme-toggle-thumb { transform: translateX(24px); }
.theme-toggle-thumb svg { width: 13px; height: 13px; }
.theme-toggle-thumb .icon-moon { display: none; }
:root[data-theme='light'] .theme-toggle-thumb .icon-moon { display: block; }
:root[data-theme='light'] .theme-toggle-thumb .icon-sun { display: none; }

.nav-toggle { display: none; }
/* The hamburger morphs into an X rather than just swapping icons, each
   bar is its own line so it can rotate/fade independently, driven purely
   by the aria-expanded state the click handler already sets. */
.nav-toggle-line {
  transform-box: fill-box;
  transform-origin: center;
  transition: transform 0.3s var(--ease-out), opacity 0.2s var(--ease);
}
.nav-toggle[aria-expanded='true'] .nav-toggle-line-top { transform: translateY(6px) rotate(45deg); }
.nav-toggle[aria-expanded='true'] .nav-toggle-line-mid { opacity: 0; transform: scaleX(0); }
.nav-toggle[aria-expanded='true'] .nav-toggle-line-bottom { transform: translateY(-6px) rotate(-45deg); }

/* ==========================================================================
   Search
   ========================================================================== */

.search {
  position: relative;
  flex: 0 1 260px;
  /* Flex items shrink toward 0 by default with nothing stopping them, so
     without a floor the nav links and header buttons were squeezing this
     down to a sliver too narrow for its own placeholder text to fit,
     "Search docs" was rendering as "Sear" cut off mid-word. This keeps
     it at a genuinely usable width instead; see the 1000px breakpoint
     below for how it stays out of the way once there truly isn't room
     for it alongside the nav links, which don't collapse until 860px. */
  min-width: 150px;
  margin-left: auto;
}

/* Below 1000px there isn't reliably enough room left over for a 150px
   search box once the full inline nav (still showing every link down to
   its own 860px collapse) and the header buttons take their share --
   between 860 and 1000px specifically the min-width above would otherwise
   win the fight and push the sign-in/account buttons past the header's
   own edge. Hiding search here, ahead of that fight rather than after
   losing it, is the same width-based degradation the wordmark text uses
   just above. */
@media (max-width: 1000px) {
  .search { display: none; }
}

/* Fixed white-based tones, same reasoning as .icon-button/.theme-toggle
   above, var(--bg)/var(--text) would swap between a dark and a light chip
   depending on site theme, when the header itself no longer does. */
.search-input {
  width: 100%;
  height: 36px;
  padding: 0 12px 0 34px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.22);
  background: rgba(255, 255, 255, 0.12);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
  color: #fff;
  font-family: inherit;
  font-size: var(--step--1);
  transition: border-color 0.15s var(--ease), background 0.15s var(--ease), box-shadow 0.15s var(--ease);
}
.search-input::placeholder { color: var(--on-brand-muted); }
.search-input:focus {
  outline: none;
  border-color: rgba(255, 255, 255, 0.5);
  background: rgba(255, 255, 255, 0.18);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), 0 0 0 3px rgba(255, 255, 255, 0.16);
}

.search-icon {
  position: absolute;
  left: 11px;
  top: 50%;
  transform: translateY(-50%);
  width: 15px;
  height: 15px;
  color: rgba(255, 255, 255, 0.65);
  pointer-events: none;
}

.search-results {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  right: 0;
  min-width: 320px;
  /* This one floats over page content, not another blurred surface, so a
     real backdrop-filter is the right call here (unlike .search-input). */
  background: color-mix(in srgb, var(--surface-raised) 80%, transparent);
  backdrop-filter: saturate(180%) blur(20px);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--shadow), inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent);
  overflow: hidden;
  z-index: 120;
  max-height: 420px;
  overflow-y: auto;
}
.search-results:empty { display: none; }

.search-result {
  display: block;
  padding: 11px 14px;
  text-decoration: none;
  color: var(--text);
  border-bottom: 1px solid var(--border);
}
.search-result:last-child { border-bottom: 0; }
.search-result:hover,
.search-result.is-active { background: var(--surface); }

.search-result-title {
  font-weight: 600;
  font-size: var(--step-0s);
  margin-bottom: 2px;
  display: flex;
  align-items: center;
  gap: 8px;
}

.search-result-kind {
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 1px 5px;
  font-weight: 600;
  flex-shrink: 0;
}

.search-result-excerpt {
  font-size: var(--step--1);
  color: var(--text-muted);
  line-height: 1.5;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.search-empty {
  padding: 16px;
  color: var(--text-muted);
  font-size: var(--step--1);
  text-align: center;
}

/* The "/" shortcut hint, a small keycap at the field's right edge. Built
   from --on-brand mixes rather than the theme's --border/--surface for the
   same reason as the field itself: it sits on the fixed header gradient
   and must not flip between a light and a dark chip with the site theme.
   It disappears once the field has focus or text (the hint has been taken)
   and never renders on touch devices, where there is no key to press. */
.search-kbd {
  position: absolute;
  right: 8px;
  top: 50%;
  transform: translateY(-50%);
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  display: grid;
  place-items: center;
  border-radius: 6px;
  border: 1px solid color-mix(in srgb, var(--on-brand) 28%, transparent);
  background: color-mix(in srgb, var(--on-brand) 10%, transparent);
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--on-brand) 18%, transparent), inset 0 -1px 0 rgba(0, 0, 0, 0.2);
  color: var(--on-brand-muted);
  font-family: var(--mono);
  font-size: var(--step--2);
  line-height: 1;
  pointer-events: none;
  transition: opacity 0.15s var(--ease), transform 0.15s var(--ease);
}
.search-input:focus ~ .search-kbd,
.search-input:not(:placeholder-shown) ~ .search-kbd { opacity: 0; transform: translateY(-50%) scale(0.9); }
@media (hover: none) {
  .search-kbd { display: none; }
}

/* Busy state. site.js sets aria-busy on the input from the first keystroke
   until the lookup renders, and this turns it into a 1px light travelling
   along the field's bottom edge, violet into teal, the site's own accent
   run as motion. Inset to sit inside the field's rounded corners. */
.search::after {
  content: '';
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: 0;
  height: 1px;
  border-radius: 1px;
  background: linear-gradient(90deg, transparent, var(--violet) 30%, var(--teal) 70%, transparent);
  background-size: 60% 100%;
  background-repeat: no-repeat;
  background-position: -150% 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s var(--ease);
}
.search:has(.search-input[aria-busy='true'])::after {
  opacity: 1;
  animation: search-shimmer 1.1s linear infinite;
}
@keyframes search-shimmer {
  from { background-position: -150% 0; }
  to   { background-position: 250% 0; }
}

/* Entrance. The panel itself fades up 4px (the .reveal vocabulary), and
   each row inside it settles in 30ms after the one above, --i being the
   row index site.js writes inline, using the same settle keyframes the
   feature cards use. :empty toggles display, so the panel animation runs
   once per open, not once per keystroke. */
.search-results:not(:empty) { animation: menu-in 0.18s var(--ease-out) both; }
@keyframes menu-in {
  from { opacity: 0; transform: translateY(4px); }
}
.search-result {
  animation: settle 0.28s var(--ease-out) both;
  animation-delay: calc(30ms * var(--i, 0));
}

/* The keyboard-highlighted row carries the docs sidebar's hairline (a 2px
   inset rule in violet over a violet wash) instead of the 3.5% white
   --surface hover tint, which was close to invisible on arrow-key travel. */
.search-result.is-active {
  box-shadow: inset 2px 0 0 var(--violet);
  background: color-mix(in srgb, var(--violet) 10%, transparent);
}

/* The closing "All results" row: a link-coloured line with the card-link
   arrow, quieter than a result but clearly a way onward. It is one more
   .search-result so the arrow keys reach it, see site.js. */
.search-result-all {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding-block: 10px;
  background: color-mix(in srgb, var(--violet) 5%, transparent);
}
.search-result-all .search-result-title {
  margin: 0;
  font-size: var(--step--1);
  color: var(--link);
}
.search-result-all svg {
  width: 14px;
  height: 14px;
  flex-shrink: 0;
  color: var(--link);
  transition: transform 0.2s var(--ease);
}
.search-result-all:hover svg,
.search-result-all.is-active svg { transform: translateX(3px); }

/* Term hits inside an excerpt, on both search surfaces. --mark is the
   palette-aware selection tint, so a highlighted word and a selected word
   are the same colour, which is the right association. */
.search-result-excerpt mark,
.result-item mark {
  background: var(--mark);
  color: inherit;
  border-radius: 3px;
  padding: 0 0.12em;
  margin: 0 -0.04em;
}

/* ==========================================================================
   Buttons
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 11px 22px;
  border-radius: 999px;
  border: 1px solid transparent;
  font: inherit;
  font-size: var(--step-0s);
  font-weight: 600;
  line-height: 1.2;
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;
  transition: transform 0.15s var(--ease), box-shadow 0.2s var(--ease),
    background 0.2s var(--ease), border-color 0.2s var(--ease), color 0.15s var(--ease);
}
.btn:active { transform: translateY(1px); }
.btn svg { width: 17px; height: 17px; flex-shrink: 0; }

.btn-primary,
.btn-secondary {
  position: relative;
  overflow: hidden;
}

.btn-primary {
  background: var(--accent-gradient);
  color: #fff;
  /* The inset highlight is a hairline of light along the top edge, like a
     button that catches light rather than a flat colour chip, a small,
     physical-feeling detail that reads as considered rather than default. */
  box-shadow: var(--glow), inset 0 1px 0 rgba(255, 255, 255, 0.3), inset 0 -1px 0 rgba(0, 0, 0, 0.12);
}
/* A diagonal sweep of light on hover, the glass catching a highlight as
   it passes, rather than a flat colour swap. */
.btn-primary::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, 0.35) 45%, transparent 60%);
  transform: translateX(-120%);
  transition: transform 0.6s var(--ease-out);
}
.btn-primary:hover::after,
.btn-primary:focus-visible::after {
  transform: translateX(120%);
}
.btn-primary:hover {
  color: #fff;
  transform: translateY(-2px);
  /* Was a hardcoded rgba(75, 58, 237, ...), Boundless's own --violet, baked
     in before the colour-theme picker existed: color-mix keeps the same
     glow shape but follows whichever palette's violet-role stop is
     actually active, instead of staying purple under, say, Mono or Mint. */
  box-shadow: 0 14px 32px -12px color-mix(in srgb, var(--violet) 55%, transparent),
    0 0 46px -6px color-mix(in srgb, var(--violet) 70%, transparent),
    inset 0 1px 0 rgba(255, 255, 255, 0.4), inset 0 -1px 0 rgba(0, 0, 0, 0.12);
  filter: brightness(1.07);
}
/* The lift undoes itself the instant a press starts, so hover -> active
   reads as one continuous gesture (rise, then compress) rather than two
   states fighting for the same transform. */
.btn-primary:active,
.btn-secondary:active {
  transform: scale(0.97) translateY(1px);
}

.btn-secondary {
  background: color-mix(in srgb, var(--bg) 35%, transparent);
  backdrop-filter: saturate(160%) blur(12px);
  -webkit-backdrop-filter: saturate(160%) blur(12px);
  color: var(--text);
  border-color: color-mix(in srgb, #fff 22%, transparent);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 30%, transparent), inset 0 -1px 1px color-mix(in srgb, #000 15%, transparent);
}
.btn-secondary:hover {
  background: color-mix(in srgb, var(--bg) 50%, transparent);
  border-color: color-mix(in srgb, var(--violet) 55%, transparent);
  color: var(--text);
  transform: translateY(-2px);
  box-shadow: 0 14px 28px -16px rgba(0, 0, 0, 0.45), inset 0 1px 0 color-mix(in srgb, #fff 30%, transparent), inset 0 -1px 1px color-mix(in srgb, #000 15%, transparent);
}
:root[data-theme='light'] .btn-secondary {
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), inset 0 -1px 1px rgba(20, 22, 40, 0.06);
  border-color: color-mix(in srgb, var(--indigo) 12%, transparent);
}

.btn-ghost { background: transparent; color: var(--text-soft); }
.btn-ghost:hover { color: var(--text); background: var(--surface); }
/* .footer-col and .site-header both sit on the fixed brand gradient rather
   than the page's own theme, so the "Sign in"/"Sign out" ghost buttons and
   the signed-in account pill living in each need the same fixed-white
   override every other themed token in those two regions gets.
   Full --on-brand, not the --soft variant plain nav links use: this text
   sits at the header's right edge, close to the gradient's most saturated
   midpoint, where the .78-alpha soft token measured under 4.5:1 against
   three of the ten palettes (Mono 4.19, Reverse 3.85, Mint 3.80). Opaque
   clears all ten with room to spare. */
.footer-col .btn-ghost,
.site-header .btn-ghost { color: var(--on-brand); }
/* The footer's sign-out control is a form button that has to read as one
   more link in its column, so it drops the button padding and weight and
   takes the column's link size; the ghost hover wash above still applies. */
.footer-col .btn-ghost { padding: 0; font-size: var(--step--1); font-weight: 400; }
.footer-col .btn-ghost:hover,
.site-header .btn-ghost:hover { color: var(--on-brand); background: rgba(255, 255, 255, 0.14); }
.site-header .btn-secondary {
  background: rgba(255, 255, 255, 0.14);
  border-color: rgba(255, 255, 255, 0.26);
  color: #fff;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.site-header .btn-secondary:hover {
  background: rgba(255, 255, 255, 0.22);
  border-color: rgba(255, 255, 255, 0.4);
  color: #fff;
}
/* The primary CTA stays legible against its own colour family by inverting
   to solid white rather than trying to out-saturate the gradient behind
   it, the one place in the header that reads as a control to press
   rather than more of the brand surface. */
.site-header .btn-primary {
  background: #fff;
  color: var(--indigo);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
}
.site-header .btn-primary:hover {
  color: var(--indigo);
  background: #fff;
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
  filter: none;
}
.site-header .btn-primary::after { display: none; }

.btn-danger {
  background: #d1344b;
  color: #fff;
  border-color: transparent;
}
.btn-danger:hover { background: #b92a3f; color: #fff; }

.btn-block { width: 100%; }
.btn-sm { padding: 8px 14px; font-size: var(--step--1); }
.btn-lg { padding: 14px 26px; font-size: var(--step-0); }

/* The global focus ring is a violet outline, which on the primary button
   means a violet ring on a violet gradient: technically present, barely
   visible. Two box-shadow rings instead, a gap in the page background and
   then a pale violet, so the ring separates from the fill the way the
   secondary button's already does. The outline stays, made transparent,
   because forced-colours mode drops box-shadows and paints transparent
   outlines in the system highlight colour: the ring survives there too.
   Placed after :hover so a focused, hovered button keeps its ring rather
   than its glow. */
.btn-primary:focus-visible {
  outline: 2px solid transparent;
  outline-offset: 2px;
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px color-mix(in srgb, var(--violet) 80%, var(--on-brand));
}
:root[data-theme='light'] .btn-primary:focus-visible {
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--indigo);
}

/* The copied state shared by every copy control (the [data-copy] buttons
   beside a repository URL and the .copy-button on code blocks, both wired
   in site.js). Teal is the site's "done" colour, the checklist tick's, so
   the label turns teal and a 12px tick draws itself in beside it, the same
   stroke-dashoffset trick the checklist rows use. The animation is what
   makes it read as a confirmation rather than a label that changed while
   you blinked; reduced motion collapses it to an instant tick. Written as
   .btn.is-copied so it outranks .btn-secondary's own colour and border. */
.btn.is-copied,
.btn.is-copied:hover,
.copy-button.is-copied,
.copy-button.is-copied:hover {
  color: var(--teal);
  border-color: color-mix(in srgb, var(--teal) 60%, transparent);
}
.copy-tick,
.btn .copy-tick {
  width: 12px;
  height: 12px;
  flex-shrink: 0;
}
.copy-tick path {
  stroke-dasharray: 20;
  stroke-dashoffset: 20;
  animation: copy-tick-draw 0.3s var(--ease-out) forwards;
}
@keyframes copy-tick-draw {
  to { stroke-dashoffset: 0; }
}

/* ==========================================================================
   Hero
   ========================================================================== */

.hero {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  /* The fluid background is the entrance view, it should be the only
     thing on screen before anyone scrolls, not a text-height band with the
     next section's edge already creeping into view. dvh over vh: on a
     phone browser the address bar changes how much of a plain vh is
     actually visible, dvh tracks the real visible area instead. */
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  align-items: center;
  padding: clamp(72px, 12vw, 128px) 0 clamp(56px, 8vw, 88px);
  background-image:
    linear-gradient(180deg, color-mix(in srgb, var(--bg) 35%, transparent) 0%, color-mix(in srgb, var(--bg) 55%, transparent) 60%, var(--bg) 100%),
    /* ?v=1: same reasoning as the @font-face src above. server.js's
       static-asset middleware only grants the long-lived immutable cache
       header to a request carrying ?v=, and this asset is rarely replaced.
       Bump this number when the actual /img/hero.webp file is ever
       replaced, or returning visitors keep the old cached image for up to
       a year. */
    url("/img/hero.webp?v=1");
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}

/* A layered atmosphere behind the wordmark: four large blurred colour
   blobs drifting independently (the CSS stand-in for a slow ambient
   video loop, no video asset, same idea of continuous, never-repeating-
   looking motion), a glass "sheen", a highlight and diagonal reflection
   drifting on top the way light moves across a pane of glass. The
   page-wide grain overlay above does the rest of the texture work.
   Every layer is slow (21-40s cycles) and stops under
   prefers-reduced-motion, same as the app. */
.hero-atmosphere {
  position: absolute;
  inset: 0;
  z-index: -1;
}
/* The depth layers (blobs, fabric, acrylic, word grid) share one wrapper
   that is 28% taller than the hero, hanging that extra height off the top
   edge, and slides down as the page scrolls so the whole stack reads as
   sitting behind the page rather than glued to it: the fade, the
   precipitation and the sheen stay pinned, which is what makes the
   difference read as depth instead of the entire hero moving. Driven by
   the scroll position itself, not a clock, so the motion is exactly as fast
   as the reader's own scroll and never a beat behind it. 22% of the
   wrapper's own height is the same distance as that 28% of the hero, so by
   the time the hero has fully scrolled away the top edge has just arrived
   at the hero's own, nothing ever shows through above it. Browsers without
   scroll-driven animations skip the @supports block and keep the layers
   still, which is what they had before. */
.hero-atmosphere-depth {
  position: absolute;
  inset: -28% 0 0;
}
@supports (animation-timeline: scroll()) {
  .hero-atmosphere-depth {
    animation: hero-depth linear both;
    animation-timeline: scroll(root);
    animation-range: 0 100vh;
  }
}
@keyframes hero-depth {
  to { transform: translate3d(0, 22%, 0); filter: brightness(0.82); }
}
.hero-atmosphere-base {
  position: absolute;
  inset: -25% -10%;
  overflow: hidden;
  filter: blur(70px);
  mask-image: linear-gradient(#000 0%, #000 46%, rgba(0, 0, 0, 0.85) 66%, transparent 100%);
  -webkit-mask-image: linear-gradient(#000 0%, #000 46%, rgba(0, 0, 0, 0.85) 66%, transparent 100%);
}
:root[data-theme='light'] .hero-atmosphere-base { opacity: 0.55; }

.atmo-blob {
  position: absolute;
  border-radius: 50%;
  will-change: transform;
}
.atmo-blob-a {
  top: 6%; left: 10%; width: 56%; height: 56%;
  background: radial-gradient(circle, color-mix(in srgb, var(--violet) 55%, transparent), transparent 70%);
  animation: atmo-drift-a 34s ease-in-out infinite alternate;
}
.atmo-blob-b {
  top: 8%; right: 6%; width: 50%; height: 50%;
  background: radial-gradient(circle, color-mix(in srgb, var(--teal) 46%, transparent), transparent 70%);
  animation: atmo-drift-b 27s ease-in-out infinite alternate;
}
.atmo-blob-c {
  bottom: -12%; left: 26%; width: 62%; height: 62%;
  background: radial-gradient(circle, color-mix(in srgb, var(--blue) 44%, transparent), transparent 70%);
  animation: atmo-drift-c 40s ease-in-out infinite alternate;
}
.atmo-blob-d {
  top: 28%; left: 42%; width: 38%; height: 38%;
  /* Was a plain white highlight blob: the one of the four that did not
     follow the palette. A teal-leaning near-white keeps the same job
     (a bright pool the others drift around) while warming or cooling with
     whatever --teal currently is. */
  background: radial-gradient(circle, color-mix(in srgb, color-mix(in srgb, var(--teal) 40%, white) 34%, transparent), transparent 70%);
  animation: atmo-drift-d 21s ease-in-out infinite alternate;
}
@keyframes atmo-drift-a { from { transform: translate(0, 0) scale(1); } to { transform: translate(7%, 9%) scale(1.14); } }
@keyframes atmo-drift-b { from { transform: translate(0, 0) scale(1); } to { transform: translate(-9%, 7%) scale(0.9); } }
@keyframes atmo-drift-c { from { transform: translate(0, 0) scale(1); } to { transform: translate(6%, -7%) scale(1.1); } }
@keyframes atmo-drift-d { from { transform: translate(0, 0) scale(1); } to { transform: translate(-7%, -9%) scale(1.18); } }

.hero-atmosphere-sheen {
  position: absolute;
  inset: 0;
  /* The highlight stops were pure white, which read as a neutral glare
     laid over Ember and Mono rather than light passing through them;
     tinting them a touch toward --teal keeps the glass reflection in the
     palette's own family. The hotspot also moved down from 4% to 16%:
     at 4% the brightest point on the whole hero sat directly behind the
     fixed header, lighting the one thing that is not the hero. */
  --hero-sheen-white: color-mix(in srgb, var(--teal) 55%, white);
  background:
    radial-gradient(circle at 50% 16%, color-mix(in srgb, var(--hero-sheen-white) 50%, transparent), transparent 46%),
    linear-gradient(116deg, transparent 18%, color-mix(in srgb, var(--hero-sheen-white) 20%, transparent) 37%, transparent 51%),
    repeating-linear-gradient(112deg, transparent 0px, transparent 16px, color-mix(in srgb, var(--hero-sheen-white) 2%, transparent) 17px, color-mix(in srgb, var(--hero-sheen-white) 2%, transparent) 18px);
  mix-blend-mode: soft-light;
  opacity: 0.72;
  animation: hero-sheen 16s ease-in-out infinite alternate;
}
:root[data-theme='light'] .hero-atmosphere-sheen { mix-blend-mode: overlay; opacity: 0.45; }

@keyframes hero-sheen {
  from { transform: translate3d(-4%, 0, 0) scale(1.04); }
  to   { transform: translate3d(5%, 1%, 0) scale(1.1); }
}

/* .hero-fabric and .hero-words sit in the same slot as the CSS blobs above,
   later in DOM order so they paint over them, but they answer to two
   different questions rather than one shared switch. The fabric shader is
   the real replacement for the blobs: it fades in for good and hides them
   underneath it the moment script confirms a WebGL frame actually
   rendered (is-fabric-active), the same "invisible until proven working"
   contract the old WebGL noise shader used, never gated on the mouse. The
   word grid is a separate, smaller texture detail that additionally needs
   is-atmosphere-revealed, toggled by mouse movement in site.js, on top of
   that. If WebGL is unavailable or the shader fails to compile,
   is-fabric-active never lands and the blobs alone keep doing the job,
   unblurred CSS, zero JS required, exactly like before. */
.hero-fabric,
.hero-words {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  opacity: 0;
  transition: opacity 1.1s var(--ease);
}
/* Rack focus on first paint: the fabric arrives soft and pulls sharp over
   1.8s while its opacity fade runs, so the swap from the blurred CSS blobs
   underneath reads as one image coming into focus rather than two
   different pictures crossfading (the blobs are blurred at 70px, the
   shader is crisp, and with opacity alone the two identities were plainly
   visible swapping for the whole 1.1s). Ends at none, not blur(0), so no
   filter layer is left sitting on a canvas that repaints every frame. */
.hero-fabric {
  filter: blur(22px);
  transition: opacity 1.1s var(--ease), filter 1.8s var(--ease-out);
}
.hero.is-fabric-active .hero-fabric {
  opacity: 1;
  filter: none;
}
/* Was display:none, flipped the instant the class landed, but the fabric
   canvas above it is still only partway through its own 1.1s opacity
   fade-in at that exact moment (this class and the canvas's opacity both
   key off the same is-fabric-active add), so for that whole window neither
   layer was at full strength: the blobs gone already, the shader not yet
   opaque. Every fresh run of this init (a normal first load, or a
   tab-discard reload after switching away and back) showed it as a pale,
   washed-out hero and header that "fixed itself" once the fade finished --
   not a one-off glitch, the same gap every single time. Fading these out in
   step with the canvas fading in, instead of yanking them out instantly,
   means there is always something at full strength covering this layer's
   slot, blobs or shader, never a moment of neither. */
.hero.is-fabric-active .hero-atmosphere-base {
  opacity: 0;
  transition: opacity 1.1s var(--ease);
  pointer-events: none;
}

/* A frosted, acrylic-style pane between the fabric shader and everything
   above it: a real backdrop-filter blur (not a filter on the fabric canvas
   itself, so the shader keeps rendering at full detail underneath, only
   the composited result reads as soft/frosted) plus a translucent tint and
   a touch of the page's own grain technique, scaled up here since this is
   meant to read as a distinct material, not just page texture. Sits below
   .hero-words on purpose: the letter grid stays crisp on top of the frost,
   the way text sits legibly on a frosted-glass panel rather than being
   frosted along with it. */
.hero-atmosphere-acrylic {
  position: absolute;
  inset: 0;
  /* Light enough to still make out the fold detail underneath, not a full
     frost: this is a soft haze over the fabric, not glass thick enough to
     hide it. */
  backdrop-filter: blur(9px) saturate(1.2);
  -webkit-backdrop-filter: blur(9px) saturate(1.2);
  /* A radial mask so the frost is a soft disc behind the title rather than
     a uniform haze over the whole hero: full strength where the wordmark
     sits, thinning out toward the edges so the fold detail of the fabric
     is left legible around it. Centred a little above the middle, where
     the wordmark actually sits in the hero's own layout. The wrapper this
     lives in hangs 28% above the hero (see .hero-atmosphere-depth), so
     the centre is placed against that taller box, not the hero. */
  mask-image: radial-gradient(ellipse 75% 60% at 50% 58%, #000 0%, #000 35%, rgba(0, 0, 0, 0.45) 70%, rgba(0, 0, 0, 0.12) 100%);
  -webkit-mask-image: radial-gradient(ellipse 75% 60% at 50% 58%, #000 0%, #000 35%, rgba(0, 0, 0, 0.45) 70%, rgba(0, 0, 0, 0.12) 100%);
  /* Tinted with the same diagonal the mark's own gradient uses (indigo
     through violet and blue to teal), not a neutral --bg wash, so the
     frosted pane itself reads as "the logo's blue" rather than plain
     frosted glass that happens to sit over blue content. */
  background: linear-gradient(
    135deg,
    color-mix(in srgb, var(--indigo) 22%, transparent),
    color-mix(in srgb, var(--violet) 18%, transparent),
    color-mix(in srgb, var(--blue) 20%, transparent),
    color-mix(in srgb, var(--teal) 14%, transparent)
  );
}
:root[data-theme='light'] .hero-atmosphere-acrylic {
  backdrop-filter: blur(8px) saturate(1.1);
  -webkit-backdrop-filter: blur(8px) saturate(1.1);
  background: linear-gradient(
    135deg,
    color-mix(in srgb, var(--indigo) 14%, transparent),
    color-mix(in srgb, var(--violet) 12%, transparent),
    color-mix(in srgb, var(--blue) 14%, transparent),
    color-mix(in srgb, var(--teal) 10%, transparent)
  );
}
.hero-atmosphere-acrylic::before {
  content: '';
  position: absolute;
  inset: 0;
  opacity: var(--hero-grain);
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Same 0.6 ratio to the dark value as before (0.03 against 0.05), kept as
   a calc so the per-palette --hero-grain lifts carry into light theme. */
:root[data-theme='light'] .hero-atmosphere-acrylic::before { opacity: calc(var(--hero-grain) * 0.6); mix-blend-mode: multiply; }

/* The letter grid is a texture detail, not a headline: screen blend and a
   modest opacity keep it reading as ambient noise with a few findable
   words in it, not a loud callout. No z-index anywhere in this stack, on
   purpose: every layer here is position:absolute with z-index left at its
   default, so paint order just follows DOM order alone. */
.hero-words {
  mix-blend-mode: screen;
  opacity: 0;
  /* Shorter than the 1.1s the fabric takes: the grid types itself in
     diagonally on the canvas (see initWordGrid in site.js), and a long
     opacity fade on top of that would hide most of the typing. */
  transition-duration: 0.4s;
}
.hero.is-atmosphere-revealed .hero-words {
  opacity: 0.5;
}
/* Screen over a near-white fabric adds nothing, the grid was simply
   invisible in light theme. Multiply with the indigo-derived inks (see
   --words-ink on :root[data-theme='light']) is the same idea in reverse. */
:root[data-theme='light'] .hero-words {
  mix-blend-mode: multiply;
}
:root[data-theme='light'] .hero.is-atmosphere-revealed .hero-words {
  opacity: 0.42;
}

/* Nothing above fades itself out toward the bottom of the hero the way the
   old shader did in its own fragment shader, blending straight to --bg was
   the only way to avoid a visible seam against the page below (an actual
   screenshot-confirmed lesson from that version). This shared overlay
   reuses the same easing shape as .hero-atmosphere-base's own mask-image
   so the whole stack still closes cleanly into the page. */
.hero-atmosphere-fade {
  position: absolute;
  inset: 0;
  /* A short top vignette under the header, then the bottom fade: the
     floating header pill had nothing to sit on, its glass edge landing on
     whichever bright fold happened to be under it, and 90px of the page
     colour thinning to nothing gives it a consistent ground on every
     palette without darkening the hero itself. */
  background:
    linear-gradient(180deg, color-mix(in srgb, var(--bg) 55%, transparent) 0%, transparent 90px),
    linear-gradient(180deg, transparent 0%, transparent 46%, color-mix(in srgb, var(--bg) 60%, transparent) 66%, var(--bg) 100%);
  pointer-events: none;
}

/* Snow or rain (see initHeroAtmosphere/initPrecipitation in site.js),
   sitting after the fade layer above rather than down with .hero-fabric
   and .hero-words: this is what keeps precipitation reading as in front
   of the atmosphere, crisp rather than sitting under the acrylic blur, and
   it still lets the fade layer's own gradient close the falling particles
   out toward the bottom of the hero the same way it already does for
   everything under it, instead of the streaks stopping dead at a visible
   edge the fade never got a chance to touch. The sheen after this one
   stays on top either way, the same glass reflection over the falling
   particles as over everything else in the hero. Draws nothing when no
   effect is selected, so an idle canvas here costs nothing. */
.hero-effect {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.hero-centered {
  max-width: 640px;
  margin-inline: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

/* The page says its own name once here, at full size, and once more at the
   very end in the footer, the same device used deliberately at the two
   structural bookends, not sprinkled in wherever the brand mark was handy. */
.hero-wordmark {
  position: relative;
  margin: 0 0 0.3em;
  font-size: clamp(3rem, 9vw, 6.5rem);
  line-height: 1;
}
/* A pseudo-element vignette behind the mark (a discrete blurred shape,
   dark-theme only) used to carry this contrast job. It worked, but at the
   strength needed it read as its own thing, a dark cloud sitting behind
   the wordmark, not a lighting effect, and looked worse than the low-
   contrast problem it was solving. A halo hugging the letterforms
   themselves (below, in the shared .hero-wordmark rule) has no shape of
   its own to be visible as one: it can only ever look like the text having
   depth, in both themes, with nothing extra to remove for light theme. */

.hero-tagline {
  font-size: var(--step-1);
  color: var(--text-soft);
  max-width: 40ch;
  margin: 0 0 1.8em;
  line-height: 1.5;
  text-wrap: pretty;
  /* .hero-wordmark's own shadow just above is what actually keeps that
     text legible over the moving fabric shader underneath; --text-soft's
     contrast is tuned against a flat page background, not a shader that
     drifts through its own bright patches, so this needed the identical
     fix, just scaled down for how much smaller this text is. Caught on
     /download, where the tagline sits over a brighter stretch of the
     animation than it happens to on the homepage, the homepage was never
     actually safe either, just luckier. */
  text-shadow: 0 2px 10px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5);
}
:root[data-theme='light'] .hero-tagline {
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
}

/* The marker-highlight device, a solid colour block behind key words on
   a poster. Redone as a translucent glass chip instead of a flat fill,
   so the emphasis reads as this site's own material rather than a marker. */
.glass-highlight {
  /* Inline, not inline-block: an inline-block cannot break across lines,
     so a long phrase ("public-domain and openly licensed" on /sources)
     dropped whole to its own line and orphaned the words before it.
     box-decoration-break: clone gives each line fragment its own full
     chip, rounded corners, border and rim included, instead of one chip
     sliced open at the line break. */
  display: inline;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  padding: 0.05em 0.45em;
  line-height: 1.35;
  /* Copy always runs a comma or period right up against the closing tag
     (no source whitespace to lean on), which otherwise lands the mark
     tucked into the pill's own rounded corner. */
  margin-right: 0.14em;
  border-radius: 8px;
  color: var(--text);
  font-weight: 600;
  /* No backdrop-filter here any more, deliberately: a filter on an inline
     element is applied to the union of its line fragments, so the moment
     the chip wrapped, the blur covered the whole width between the two
     fragments and smeared the words before it. The tint is a shade
     stronger than the blurred version needed (40%, was 30%) so the text
     stays legible over the hero fabric without it. */
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  border: 1px solid color-mix(in srgb, #fff 22%, transparent);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 25%, transparent);
}
:root[data-theme='light'] .glass-highlight {
  border-color: color-mix(in srgb, var(--indigo) 12%, transparent);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}

.hero-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 22px;
  margin-bottom: 26px;
}

.hero-shot {
  display: flex;
  justify-content: center;
}

/* This one screenshot now carries its own figcaption, matching every other
   shot on the page, but it deliberately does not take the .shot class:
   .shot .feature-card sets width to 100 percent, which would fight the 480
   pixel cap .feature-card-hero wants to keep, on specificity alone. So the
   figure and caption get their own small rule here instead, styled to look
   identical to .shot's. */
.hero-shot figure { margin: 0; }
.hero-shot figcaption {
  margin-top: 14px;
  font-size: var(--step--1);
  color: var(--text-muted);
  line-height: 1.5;
  text-align: center;
}
.hero-shot figcaption strong { display: block; color: var(--text); font-weight: 650; margin-bottom: 2px; }

/* The second action is a fact, not a competing offer. Weight (a filled
   button vs. plain text) says which one matters, so neither needs a label
   announcing it as "primary". */
.text-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--text-soft);
  text-decoration: none;
  padding-bottom: 2px;
  background-image: var(--accent-gradient);
  background-repeat: no-repeat;
  background-position: left bottom;
  background-size: 0% 2px;
  transition: color 0.2s var(--ease), background-size 0.3s var(--ease-out);
}
.text-link svg { width: 15px; height: 15px; transition: transform 0.2s var(--ease); }
.text-link:hover,
.text-link:focus-visible { color: var(--text); background-size: 100% 2px; }
.text-link:hover svg,
.text-link:focus-visible svg { transform: translateX(3px); }

.hero-facts {
  color: var(--text-muted);
  font-size: 0.87rem;
  margin: 0;
}
.hero-facts span { margin: 0 9px; opacity: 0.6; }

/* ==========================================================================
   Feature cards. Empty placeholders. Real screenshots go here at release;
   until then this is deliberately just a slot, not a piece of finished
   design, so a plain dashed frame and a generic image glyph rather than the
   colour-washed icon illustration these used to carry.
   ========================================================================== */

.feature-card {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  border-radius: var(--radius-xl);
  min-height: 260px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface);
  /* var(--border-strong) computes to only ~1.5:1 against --bg here, well
     under the 3:1 non-text minimum, in both themes, a plain dashed frame
     around an intentionally-empty slot needs to actually read as a frame.
     A dedicated, more opaque rgba (2px, not 1.5px) rather than reaching for
     --border-strong itself: that token is reused all over the page at its
     current, deliberately-subtle opacity, and this is the one place asking
     for a stronger version of it. 0.4/0.55 alpha computed to ~3.8:1 (dark)
     and ~3.9:1 (light) against --bg. */
  border: 2px dashed rgba(255, 255, 255, 0.4);
  animation: settle 0.9s var(--ease-out) both;
}
:root[data-theme='light'] .feature-card {
  border-color: rgba(20, 22, 40, 0.55);
}
@keyframes settle {
  from { transform: translateY(16px); opacity: 0; }
  to   { transform: translateY(0); opacity: 1; }
}

/* opacity: 0.5 on top of --text-muted computed to ~2.3:1 against --bg in
   dark theme (under 3:1) and ~2.0:1 in light. Confirmed the same way as
   the border above. 0.8 clears 3:1 in both themes (~4.2:1 dark, ~3.3:1
   light) while still reading as a muted placeholder glyph rather than full
   body-text weight. */
.feature-card-placeholder-icon {
  width: 15%;
  min-width: 34px;
  max-width: 56px;
  aspect-ratio: 1;
  color: var(--text-muted);
  opacity: 0.8;
}
.feature-card-placeholder-icon svg { width: 100%; height: 100%; }

/* ==========================================================================
   Screenshots
   ========================================================================== */

.shot-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: clamp(16px, 3vw, 32px);
  align-items: start;
}

.shot figure { margin: 0; }

/* A feature card fills its column. Unlike a phone screenshot, a wide
   card is exactly the right shape here, not something to cap and centre. */
.shot .feature-card { width: 100%; box-shadow: var(--shadow); }
.feature-card-hero { width: min(480px, 100%); margin-inline: auto; }

.shot figcaption {
  margin-top: 14px;
  font-size: var(--step--1);
  color: var(--text-muted);
  line-height: 1.5;
  text-align: center;
}
.shot figcaption strong {
  display: block;
  color: var(--text);
  font-weight: 650;
  margin-bottom: 2px;
}

/* Staggered vertical offsets so a row of three does not read as a filmstrip.
   Dropped on narrow screens where the grid is a single column. */
@media (min-width: 861px) {
  .shot-grid.stagger .shot:nth-child(even) { transform: translateY(26px); }
}

/* Screenshots inside documentation prose. */
.prose img {
  width: 100%;
  max-width: 300px;
  border-radius: 18px;
  border: 1px solid var(--border-strong);
  box-shadow: var(--shadow);
  margin: 1.6em 0;
  display: block;
}

/* ==========================================================================
   Sections
   ========================================================================== */

.section { padding: clamp(48px, 8vw, 88px) 0; }
.section-tight { padding: clamp(36px, 5vw, 56px) 0; }

/* Marks "01 The idea" as the page's high point, the first full beat after
   the hero, with a soft radial wash bleeding down from the top of the
   section rather than the flat background every other section sits on.
   Kept faint on purpose: it should read as an echo of the hero fading in
   above it, not a second hero competing for attention. */
.section-feature {
  position: relative;
  isolation: isolate;
}
.section-feature::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  opacity: 0.4;
  background: radial-gradient(60% 55% at 50% 0%, color-mix(in srgb, var(--violet) 16%, transparent), transparent 70%);
}

/* A full bleed surface tint rather than another section on the flat page
   background, so "05 A look inside" (the screenshots and the platform row
   together) reads as its own distinct chapter, the page's proof section,
   instead of blending into the sections above and below it. */
.section-surface {
  position: relative;
  background: var(--bg-soft);
  /* The one big breath on the home page. Every other section pairs
     .section with .section-tight, so the gap between any two of them
     came out identical (88 + 56 either way round) and the page read as a
     metronome; the proof chapter gets a visibly larger gap on both sides
     so arriving at it feels like turning to a new part of the document. */
  padding-block: clamp(72px, 10vw, 128px);
}
/* A single centred hairline at the top edge of the surface, the only
   "rule" moment above the footer. Teal because it is the accent this site
   uses for a considered detail (the eyebrow, the tick), and at 55% so it
   reads as a printer's mark rather than a divider. */
.section-surface::before {
  content: '';
  position: absolute;
  top: 0;
  left: 50%;
  width: 48px;
  height: 1px;
  transform: translateX(-50%);
  background: color-mix(in srgb, var(--teal) 55%, transparent);
}

/* Two tight sections in a row (02 Sources into 03 Reading into 04
   Appearance, the split rows) read as one run of chapters, so the seam
   between them closes further than a tight section's own padding. */
.section-tight + .section-tight { padding-top: clamp(24px, 3vw, 36px); }

.section-head {
  max-width: 620px;
  margin-bottom: clamp(28px, 4vw, 44px);
}
.section-head.center { margin-inline: auto; text-align: center; }

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-size: var(--step--2);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--teal);
  margin-bottom: 12px;
}

/* An editorial numeral before the label. "01" rather than a bullet or an
   icon. Borrowed from print section markers. Optional: plain eyebrows
   with no index still line up correctly without one. */
.eyebrow-index {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 500;
  font-size: var(--step-0);
  text-transform: none;
  letter-spacing: 0;
  color: var(--text-muted);
}

/* The one eyebrow that is also a button, revealing the ambient-effect row
   in the Appearance section (see .effect-row and site.js). Only the
   browser's own button chrome gets reset here, background, border,
   padding, the button element's own font-family and line-height, so the
   .eyebrow rule above still supplies every typographic property that
   actually needs to match the other, non-interactive eyebrows on the
   page: no font shorthand here, which would reset those back to the
   button default instead of inheriting them. */
.eyebrow-trigger {
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  font-family: inherit;
  line-height: inherit;
  text-align: inherit;
  cursor: pointer;
}

.section-head h2, .section-head h1 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-3); letter-spacing: -0.01em; margin-bottom: 0.35em; }
.section-head p { color: var(--text-soft); font-size: var(--step-1); line-height: 1.55; margin: 0; }

/* The same device one step down the scale, for a heading that groups a
   card grid inside a utility page (the platform groups on /download)
   rather than opening a chapter of the home page: eyebrow-index and serif
   h2 intact, but at --step-2 with a shorter drop to the grid below, so
   the page's own first-run head further down still outranks it. */
.section-head-compact { margin-bottom: clamp(18px, 2.5vw, 26px); }
.section-head-compact h2 { font-size: var(--step-2); margin-bottom: 0.25em; }
.section-head-compact p { font-size: var(--step-0); max-width: 56ch; }

/* Two steps down: an eyebrow with a serif label under it, for a titled
   group inside a page that already has its h1 (the badge row on a
   profile). Left-aligned and unconstrained, since it sits in a column
   rather than opening a chapter. */
.section-head-minor { margin-top: 36px; margin-bottom: 14px; text-align: left; max-width: none; }
.section-head-minor h2 { font-size: var(--step-0l); margin-bottom: 0; }

/* The date line on /terms and /privacy used to sit in the lede slot at
   --step-1, as if "Last updated" were the page's summary. It is metadata,
   so it borrows the docs header's .doc-meta row, left-aligned under the
   real lede rather than centred the way the docs header runs it. */
.section-head .doc-meta {
  justify-content: flex-start;
  margin: 18px 0 0;
}

/* ==========================================================================
   Cards
   ========================================================================== */

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
  gap: clamp(14px, 2vw, 20px);
}
.card {
  position: relative;
  padding: 26px;
  border-radius: var(--radius-lg);
  background: var(--surface);
  border: 1px solid var(--border);
  transition: transform 0.25s var(--ease-out), border-color 0.25s var(--ease), background 0.25s var(--ease), box-shadow 0.25s var(--ease);
  overflow: hidden;
  --spot-x: 50%;
  --spot-y: 50%;
}

/* A one pixel gradient hairline along the top edge, revealed on hover. It
   is the same trick the app uses on its cards. */
.card::before {
  content: '';
  position: absolute;
  inset: 0 0 auto 0;
  height: 1px;
  background: var(--accent-gradient);
  opacity: 0;
  transition: opacity 0.25s var(--ease);
  z-index: 1;
}

/* A soft light that follows the cursor, set via --spot-x/--spot-y in
   site.js. Absent on touch devices (see the hover:hover guard there),
   where a fixed position would just look like an off-centre smudge. */
.card::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(360px circle at var(--spot-x) var(--spot-y), color-mix(in srgb, var(--violet) 14%, transparent), transparent 70%);
  opacity: 0;
  transition: opacity 0.4s var(--ease);
  pointer-events: none;
}
.card:hover {
  transform: translateY(-3px);
  border-color: var(--border-strong);
  box-shadow: 0 24px 48px -28px rgba(0, 0, 0, 0.5);
}
.card:hover::before { opacity: 1; }
.card:hover::after { opacity: 1; }
.card > * { position: relative; z-index: 2; }

.card h3 { font-size: var(--step-0l); margin-bottom: 0.42em; }
.card p { color: var(--text-soft); font-size: var(--step-0s); margin: 0; }
.card .pill, .doc-card-body .pill { margin-bottom: 10px; }

/* A glossy "keycap" rather than a flat gradient chip: bigger, tilted a few
   degrees via a local perspective, a highlight overlay standing in for a
   curved glass/plastic top catching light, and a layered shadow for real
   depth instead of one flat glow. Stands upright and lifts on card hover. */
.card-icon {
  display: grid;
  place-items: center;
  width: 64px;
  height: 64px;
  border-radius: 18px;
  /* Scrimmed the same way and for the same reason as .theme-toggle-thumb
     above, see its comment for the actual per-palette numbers. */
  background: linear-gradient(rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.28)), var(--accent-gradient);
  color: var(--on-brand);
  margin-bottom: 20px;
  position: relative;
  isolation: isolate;
  transform: perspective(600px) rotateX(6deg);
  transform-origin: bottom center;
  box-shadow:
    0 20px 40px -16px color-mix(in srgb, var(--violet) 55%, transparent),
    inset 0 2px 0 rgba(255, 255, 255, 0.5),
    inset 0 -3px 6px rgba(0, 0, 0, 0.25);
  transition: transform 0.3s var(--ease-out), box-shadow 0.3s var(--ease-out);
}
.card-icon::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0.12) 35%, transparent 60%);
  pointer-events: none;
}
.card-icon svg {
  width: 28px;
  height: 28px;
  position: relative;
  z-index: 1;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25));
}
.card:hover .card-icon {
  transform: perspective(600px) rotateX(0deg) translateY(-4px);
  box-shadow:
    0 28px 50px -16px color-mix(in srgb, var(--violet) 65%, transparent),
    inset 0 2px 0 rgba(255, 255, 255, 0.55),
    inset 0 -3px 6px rgba(0, 0, 0, 0.25);
}

a.card { text-decoration: none; color: inherit; display: block; }

/* A plain variant, keeping the icon, heading, and paragraph but dropping
   the boxed background, border, and hover glow. Used for the platform row
   in "05 A look inside", which sits right under a screenshot grid and reads
   better as a quiet caption strip than as a second set of boxed cards this
   soon after the ones in "01 The idea". */
.card-plain {
  background: none;
  border: none;
  padding: 0;
  box-shadow: none;
}
.card-plain::before, .card-plain::after { display: none; }
.card-plain:hover { transform: none; box-shadow: none; }

.card-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-top: 14px;
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--link);
  padding-bottom: 2px;
  background-image: var(--accent-gradient);
  background-repeat: no-repeat;
  background-position: left bottom;
  background-size: 0% 2px;
  transition: background-size 0.3s var(--ease-out);
}
.card:hover .card-link,
.card-link:hover,
.card-link:focus-visible { background-size: 100% 2px; }
.card-link svg { width: 14px; height: 14px; transition: transform 0.2s var(--ease); }
.card:hover .card-link svg { transform: translateX(3px); }

/* ==========================================================================
   Feature split rows
   ========================================================================== */

.split {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: clamp(28px, 5vw, 64px);
  align-items: center;
}
.split + .split { margin-top: clamp(48px, 7vw, 84px); }
.split.reverse > *:first-child { order: 2; }

.split h3 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-2); letter-spacing: -0.01em; margin-bottom: 0.5em; }
.split p { color: var(--text-soft); font-size: var(--step-0); }

/* A quieter sub-heading for a block nested inside a bigger section, the
   platform row inside "05 A look inside" being the one case so far. Giving
   it the full .section-head treatment would just repeat the eyebrow and h2
   pattern used two paragraphs above it on the same page, so this is sized
   down instead, off .split's own h3 scale rather than .section-head's. */
.subhead { max-width: 560px; margin-bottom: 24px; }
.subhead h3 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-2); letter-spacing: -0.01em; margin-bottom: 0.3em; }
.subhead p { color: var(--text-soft); font-size: var(--step-0); margin: 0; }

.checklist { list-style: none; padding: 0; margin: 1.2em 0 0; display: grid; gap: 11px; }
.checklist li {
  display: flex;
  gap: 11px;
  align-items: flex-start;
  color: var(--text-soft);
  font-size: var(--step-0s);
  line-height: 1.5;
}
.checklist svg { width: 18px; height: 18px; color: var(--teal); flex-shrink: 0; margin-top: 3px; }

/* The ticks draw themselves as each row arrives, instead of sitting
   pre-drawn on a row that is still rising in. The row's own reveal delay
   (the nth-child stagger in the Reveal block) is mirrored here as
   --reveal-delay, so the tick lands 0.2s after its own row rather than
   after the first one. Gated on .has-js like .reveal itself: without the
   script nothing would ever add .is-visible, and the ticks must not be
   left undrawn on a page that never animates. */
.has-js .checklist svg path {
  stroke-dasharray: 24;
  stroke-dashoffset: 24;
  transition: stroke-dashoffset 0.35s var(--ease-out);
  transition-delay: calc(var(--reveal-delay, 0s) + 0.2s);
}
.has-js .checklist .reveal.is-visible svg path { stroke-dashoffset: 0; }
.checklist li:nth-child(2) { --reveal-delay: 0.09s; }
.checklist li:nth-child(3) { --reveal-delay: 0.18s; }
.checklist li:nth-child(4) { --reveal-delay: 0.27s; }

/* Swatch strip used to show the accent themes, and, now that the footer's
   own colour theme control is gone, the real thing itself: each swatch is
   a real button underneath (see site.js), but every rule here is written
   to keep it looking exactly like the plain decorative strip it always
   was, no background colour, padding, or line-height left over from the
   browser's own button chrome, and no hover state stronger than the lift
   this row already had before any swatch did anything. Finding out these
   are clickable at all is meant to be the discovery, so nothing here is
   allowed to give that away first. */
.swatches { display: flex; flex-wrap: wrap; gap: 12px; }
.swatch {
  width: 54px;
  height: 54px;
  border-radius: 16px;
  border: 1px solid var(--border-strong);
  background-color: transparent;
  padding: 0;
  margin: 0;
  font-family: inherit;
  line-height: inherit;
  position: relative;
  transition: transform 0.2s var(--ease-out);
  cursor: pointer;
}
.swatch:hover { transform: translateY(-4px) scale(1.06); }
.swatch span {
  position: absolute;
  /* Wider than the swatch on both sides: at --step--2 the longer names
     ("Boundless", "Blossom") are wider than the 54px square, and a label
     box the swatch's own width would start-align the overflow and sit the
     name off centre. Only one label shows at a time, so the boxes may
     overlap. */
  inset: auto -14px -22px;
  text-align: center;
  white-space: nowrap;
  font-size: var(--step--2);
  color: var(--text-muted);
  opacity: 0;
  transition: opacity 0.2s var(--ease);
}
.swatch:hover span, .swatch:focus-visible span { opacity: 1; }
.swatch-row { margin-bottom: 26px; }

/* The account page's profile-accent picker. Same swatch treatment as the row
   above, and deliberately not a new control: what changes is only what a
   swatch *is* here, a real radio input inside the profile form rather than a
   button that runs script, so choosing an accent saves through the same POST
   as the display name and the bio and works with scripting off.
   The gradient is not repeated as ten more hex triples: each swatch carries
   data-accent, which the palette table near the top of this file uses to
   re-declare the four stops for that one element, so this single rule paints
   all ten from the same values everything else reads. */
.accent-field { border: 0; padding: 0; margin-bottom: 22px; min-width: 0; }
.accent-field legend {
  display: block;
  padding: 0;
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--text);
  margin-bottom: 10px;
}
/* A wider row gap than the showcase strip uses. Its labels only appear on
   hover, one at a time, so they may overlap the row below; the checked one
   here is visible permanently, and it hangs 22px under its swatch.
   A fixed five-column grid rather than a wrapping flex row: ten swatches
   flex-wrapped in the panel's width landed as nine and an orphan, and 5 + 5
   is the only split that reads as deliberate. The panel is never narrower
   than five swatches and four gutters (318px), so it never needs to wrap. */
.accent-swatches {
  display: grid;
  grid-template-columns: repeat(5, 54px);
  gap: 30px 12px;
  margin-bottom: 24px;
}
.accent-swatch {
  display: block;
  background-image: linear-gradient(145deg, var(--indigo), var(--violet), var(--blue), var(--teal));
  cursor: pointer;
}
/* The input is the control, so it is kept in the layout and in the tab order
   rather than display: none, and only made invisible. Focus is drawn on the
   label around it by the :has() rule below. */
.accent-swatch input {
  position: absolute;
  inset: 0;
  opacity: 0;
  margin: 0;
  cursor: pointer;
}
/* The chosen one wears a ring in the page's own ink rather than a tick glyph
   on top: several of these palettes are near-black or near-white at one stop,
   and a mark drawn on the swatch itself would disappear into one of them. */
.accent-swatch:has(input:checked) {
  border-color: var(--text);
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--text);
}
.accent-swatch:has(input:focus-visible) {
  outline: 2px solid var(--text);
  outline-offset: 3px;
}
/* The showcase row hides its labels until hover, because finding out those
   are clickable is the point there. Here they are form controls, so the
   chosen one keeps its name visible at all times and the rest still appear on
   hover and on focus. */
.accent-swatch:has(input:checked) span,
.accent-swatch:has(input:focus-visible) span { opacity: 1; }

/* The account page's profile-picture control, sitting next to the banner's
   because the two controls sit next to each other on the page. */
.avatar-field { margin-bottom: 22px; }
/* Deliberately not a round copy of .banner-preview, and the two differences
   are both forced by the code behind them rather than chosen for looks.

   The disc is an opaque surface instead of the accent gradient because there
   is no no-image state for a gradient to show through. lib/avatars.js always
   returns a URL, and when nobody has uploaded anything that URL is this app's
   own default picture rather than a file on the storage host, so this frame
   is never sitting behind an empty src the way the banner's is before a first
   upload. A gradient here would only ever be a rim nobody sees.

   And the frame is a circle because it is a circle on the public profile.
   Someone choosing a picture is choosing what a centred square crop of it
   looks like, so they should be shown the shape they are actually going to
   get rather than a rounded rectangle that quietly loses its corners one page
   later. */
.avatar-preview {
  position: relative;
  width: 96px;
  height: 96px;
  border-radius: 50%;
  border: 1px solid var(--border);
  overflow: hidden;
  background: var(--surface-solid);
  margin-bottom: 14px;
}
.avatar-preview-image {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The account page's profile-banner control. Sits under the accent picker
   because that is what it layers over: the box below always paints the accent
   gradient, and the uploaded image, when there is one, covers it. With no
   image the box is simply the accent, which is exactly what the profile shows.

   Two stops written out here rather than var(--accent-gradient), which looks
   like it would do the same thing and does not. A custom property's own var()
   references are substituted where the property is *declared*, and
   --accent-gradient is declared once on :root, so its --violet and --teal
   resolve against :root and what inherits down is an already-finished
   gradient in the *visitor's* palette. Setting data-accent on this element
   redefines --violet and --teal here, far too late to change it. Caught by
   reading the computed background of a nova preview and finding royal in it,
   the palette this browser happened to be set to. Naming the stops in this
   rule is what makes the substitution happen on the element that carries the
   accent, which is the whole requirement: a profile's colour is its owner's
   choice and must not follow whoever is looking at it.

   Still only two stops, not .profile-card's four-stop recipe. This is a
   control showing a crop, not a facsimile of the card, and duplicating that
   recipe would create a second place to keep in step with the first. */
.banner-field { margin-bottom: 22px; }
/* The same label a <legend> or a <label> gets, for the two blocks that are
   neither a fieldset nor a single control: the banner and the badge row. */
.banner-label,
.field-label {
  display: block;
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--text);
  margin-bottom: 10px;
}
.banner-preview {
  position: relative;
  aspect-ratio: 4 / 1;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  overflow: hidden;
  background-image: linear-gradient(135deg, var(--violet), var(--teal));
  margin-bottom: 14px;
}
/* An <img> with an empty src requests nothing and, with an empty alt, paints
   nothing, so one element covers both states and the script only ever has to
   set src. */
.banner-preview-image {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* From here down the picture and the banner share the rules rather than
   owning one each. The actions row, the visually hidden input and the status
   line are the same three parts doing the same three jobs for both controls,
   and two copies of that would only give a later change somewhere to be
   applied to one control and forgotten on the other. */
.banner-actions,
.avatar-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
/* The label is the button, so the input itself only has to stay reachable by
   keyboard and assistive tech, not visible. */
.banner-file,
.avatar-file {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
/* Its own line under the two buttons, and it holds that line whether or not
   there is anything to say, so reporting a failure never shifts the controls
   above it down the page. */
.banner-status,
.avatar-status {
  flex: 1 1 100%;
  margin: 0;
  font-size: var(--step--1);
  color: var(--text-muted);
  min-height: 1.45em;
}
.banner-status.is-error,
.avatar-status.is-error { color: var(--field-error-ink); }

/* The ambient-effect picker, a second easter egg tucked behind the
   section's own eyebrow (see .eyebrow-trigger) rather than shown outright.
   The row sits in the document the whole time behind the hidden attribute,
   so site.js only ever has to remove that attribute and add is-open a
   moment later, which is what turns this into a fade instead of the row
   just popping into the layout at full opacity the instant it unhides. */
.effect-row {
  display: flex;
  flex-wrap: wrap;
  gap: 18px;
  margin-top: 14px;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out);
}
/* An author stylesheet rule always wins over the browser's own default
   [hidden] { display: none }, tie or no tie on specificity, since the two
   live in different cascade origins entirely. Without this line the plain
   .effect-row rule above was doing exactly that, quietly overriding the
   hidden attribute the moment it set its own display: flex, which left the
   row sitting in the page's layout and reachable by keyboard and screen
   reader before the eyebrow was ever clicked, opacity: 0 or not. Matching
   specificity to that same rule, rather than reaching for !important, is
   what actually wins the tie back. */
.effect-row[hidden] { display: none; }
.effect-row.is-open { opacity: 1; transform: none; }

/* Plain text buttons on purpose, quieter even than the swatches above
   since there is no gradient fill here to give them shape, just the same
   restraint: no border, no fill, a colour shift on hover or focus and
   nothing else. */
.effect-option {
  background: none;
  border: 0;
  padding: 2px 0;
  font-family: inherit;
  font-size: var(--step--2);
  letter-spacing: 0.02em;
  color: var(--text-muted);
  cursor: pointer;
  transition: color 0.15s var(--ease);
}
.effect-option:hover,
.effect-option:focus-visible {
  color: var(--text-soft);
}

/* ==========================================================================
   Prose
   ========================================================================== */

.prose {
  /* One measure for every long-form page: a character count rather than
     the 720px --reading token, so docs and posts share the same line
     length in the same type instead of two pixel widths that happened to
     be close. .post-header and .post-nav derive theirs from this figure. */
  max-width: 66ch;
  font-size: var(--step-0);
  line-height: 1.75;
  color: var(--text-soft);
}
.prose.wide { max-width: 100%; }

/* The display serif comes back inside long-form pages. The page title on
   a doc, a post, or /terms is serif, and every h2 under it used to drop
   to the bold sans, so the pages people spend longest on were the only
   ones where the site's own typographic voice never returned. h3 stays
   in the sans on purpose: h2 and h3 now differ by voice, not just size. */
.prose > h2 {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-2);
  letter-spacing: -0.01em;
  line-height: 1.15;
  margin-top: 2.4em;
  margin-bottom: 0.6em;
  color: var(--text);
}
.prose > h2:first-child { margin-top: 0; }

.prose > h3 {
  font-size: var(--step-0l);
  margin-top: 1.9em;
  margin-bottom: 0.5em;
  color: var(--text);
}

.prose h2 .heading-anchor,
.prose h3 .heading-anchor {
  margin-left: 9px;
  color: var(--text-muted);
  text-decoration: none;
  opacity: 0;
  font-weight: 400;
  transition: opacity 0.15s var(--ease);
}
.prose h2:hover .heading-anchor,
.prose h3:hover .heading-anchor,
.heading-anchor:focus { opacity: 1; }

.prose ul, .prose ol { padding-left: 1.35em; margin: 0 0 1.2em; }
.prose li { margin-bottom: 0.5em; }
.prose li::marker { color: var(--text-muted); }

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

/* The site's pull-quote voice: the display serif in italic at the lede
   size, behind a teal rule (mixed down so it reads as an accent, not a
   stripe). Used by the blog and by the quotes already in three docs. */
.prose blockquote {
  margin: 1.8em 0;
  padding: 4px 0 4px 22px;
  border-left: 2px solid color-mix(in srgb, var(--teal) 55%, transparent);
  color: var(--text);
  font-family: var(--font-display);
  font-style: italic;
  font-size: var(--step-1);
  line-height: 1.45;
  letter-spacing: -0.005em;
}
.prose blockquote p:last-child { margin-bottom: 0; }
.prose blockquote :not(pre) > code { font-style: normal; }

.prose :not(pre) > code {
  font-family: var(--mono);
  font-size: 0.87em;
  background: var(--code-bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 0.14em 0.4em;
  color: var(--text);
  word-break: break-word;
}

.code-block {
  margin: 1.6em 0;
  padding: 18px 20px;
  border-radius: var(--radius);
  background: var(--code-bg);
  border: 1px solid var(--border);
  overflow-x: auto;
  font-family: var(--mono);
  font-size: var(--step--1);
  line-height: 1.65;
  position: relative;
  tab-size: 2;
}
.code-block code { font-family: inherit; background: none; border: 0; padding: 0; color: var(--text); }

/* The language label sits in the corner rather than in a heavy toolbar. */
.code-block[data-lang]::before {
  content: attr(data-lang);
  position: absolute;
  top: 8px;
  right: 12px;
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
  font-family: var(--font);
  font-weight: 600;
  pointer-events: none;
}

.copy-button {
  position: absolute;
  top: 8px;
  right: 8px;
  /* Flex so the drawn tick (.copy-tick, see the Buttons block) sits on the
     label's centreline once the button flips to .is-copied. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  opacity: 0;
  padding: 5px 9px;
  font-size: var(--step--2);
  font-weight: 600;
  border-radius: 7px;
  border: 1px solid var(--border-strong);
  background: var(--surface-raised);
  color: var(--text-soft);
  cursor: pointer;
  font-family: var(--font);
  transition: opacity 0.15s var(--ease), color 0.15s var(--ease);
  z-index: 2;
}
.code-block:hover .copy-button,
.copy-button:focus,
.copy-button.is-copied { opacity: 1; }
.copy-button:hover { color: var(--text); }

/* Syntax tokens. Deliberately low contrast between kinds: the point is to
   separate structure from content, not to make a rainbow. */
.tok-comment { color: var(--text-muted); font-style: italic; }
.tok-string { color: #7fd6a6; }
.tok-number { color: #f2b263; }
.tok-keyword { color: #a79bff; font-weight: 600; }
.tok-literal { color: #f2b263; }
.tok-function { color: #79b8ff; }
.tok-property { color: #8fd2e8; }
.tok-key { color: #79b8ff; }
.tok-variable { color: #f2b263; }
.tok-flag { color: #a79bff; }
.tok-attribute { color: #8fd2e8; }
.tok-type { color: #8fd2e8; }
.tok-tag { color: #a79bff; }

:root[data-theme='light'] .tok-string { color: #157a4c; }
:root[data-theme='light'] .tok-number,
:root[data-theme='light'] .tok-literal,
:root[data-theme='light'] .tok-variable { color: #9a5b06; }
:root[data-theme='light'] .tok-keyword,
:root[data-theme='light'] .tok-flag,
:root[data-theme='light'] .tok-tag { color: #5b3ed6; }
:root[data-theme='light'] .tok-function,
:root[data-theme='light'] .tok-key { color: #1758b8; }
:root[data-theme='light'] .tok-property,
:root[data-theme='light'] .tok-attribute,
:root[data-theme='light'] .tok-type { color: #10708a; }

.table-scroll {
  overflow-x: auto;
  margin: 1.6em 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.table-scroll table { border-collapse: collapse; width: 100%; font-size: var(--step--1); margin: 0; }
.table-scroll th, .table-scroll td {
  text-align: left;
  padding: 11px 15px;
  border-bottom: 1px solid var(--border);
  vertical-align: top;
}
.table-scroll th {
  background: var(--surface);
  font-weight: 650;
  color: var(--text);
  white-space: nowrap;
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
.table-scroll tr:last-child td { border-bottom: 0; }
.table-scroll td code { white-space: nowrap; }

/* ==========================================================================
   Doc tones and banners
   -------------------------------------------------------------------------
   Every doc gets one accent tone, drawn from the app's own named accent
   themes rather than an arbitrary palette, and rendered as a small glass
   banner: a diagonal tint plus two soft highlights, the same layered
   material as the hero atmosphere, at a scale that fits a card or a page
   header instead of the whole viewport.
   ========================================================================== */

/* Each named tone used to be a fixed hex triple of its own, fully-saturated
   regardless of whatever palette the visitor actually had active, so a Void
   or Mono page (both meant to read as close to monochrome) still showed a
   rainbow of hardcoded doc/post banners sitting on top of it. The identity
   variety per doc is worth keeping, a docs index where every tile were the
   same colour would be far harder to scan, so each tone name stays, but
   what it actually resolves to is now a distinct rotation through this
   site's own four live stops, --indigo/--violet/--blue/--teal, the same
   ones every other brand-derived token in this file already reads. That
   makes every banner retint with the active palette the same way the
   header, the buttons, and the wordmark already do, while the nine tones
   stay visually distinct from each other under any single palette, since
   each one walks the same four-colour cycle starting from a different
   point and in a different direction rather than all converging on the
   same two or three stops. tone-boundless keeps the exact violet, blue,
   teal order its old hardcoded hex already matched, so it reads as the
   site's own default accent gradient rather than a rotation of it. */
.tone-boundless { --tone-a: var(--violet); --tone-b: var(--blue); --tone-c: var(--teal); }
/* nova used to be indigo, violet, blue: three stops that sit next to each
   other on every palette's own ramp from dark to light, since none of the
   four brand tokens is ever more than one step away from its neighbour.
   The tile read as a single flat colour rather than a gradient, which is
   what android and announcements both looked like sitting one above the
   other, the same tone twice. --indigo is the one token that is the
   darkest stop on every palette this file defines, and --teal is the
   lightest on every one of them except Void, which keeps it dark on
   purpose; anchoring a tone on both is what guarantees it actually spans
   something, on any accent a visitor has chosen. */
.tone-nova      { --tone-a: var(--indigo); --tone-b: var(--blue); --tone-c: var(--teal); }
.tone-ink       { --tone-a: var(--violet); --tone-b: var(--blue); --tone-c: var(--indigo); }
.tone-reverse   { --tone-a: var(--teal); --tone-b: var(--indigo); --tone-c: var(--violet); }
.tone-blossom   { --tone-a: var(--indigo); --tone-b: var(--blue); --tone-c: var(--teal); }
.tone-ember     { --tone-a: var(--violet); --tone-b: var(--teal); --tone-c: var(--indigo); }
.tone-mint      { --tone-a: var(--teal); --tone-b: var(--blue); --tone-c: var(--violet); }
/* royal is the one tone name that promises a specific look, purple and
   gold, rather than just a label. The other seven are free to rotate
   through this site's four live accent stops and still feel like
   themselves; royal cannot, because none of those four tokens is gold on
   most of the ten palettes (it's cyan on Boundless, green on Mint, grey on
   Ink and Mono), so rotating it the same way every sibling tone does was
   never going to read as royal, only as whatever purple-to-dark the active
   accent happened to produce. This is the one deliberate exception: a
   literal, fixed gold rather than a palette token, so the tile is purple
   deepening to shadow with a real gold edge on every accent, including the
   ones with no gold anywhere in their own four stops. */
.tone-royal     { --tone-a: var(--violet); --tone-b: var(--indigo); --tone-c: #e8b84b; }

.doc-banner {
  position: relative;
  overflow: hidden;
  background:
    radial-gradient(circle at 78% 14%, rgba(255, 255, 255, 0.55), transparent 32%),
    radial-gradient(circle at 4% 100%, rgba(255, 255, 255, 0.38), transparent 32%),
    linear-gradient(135deg, var(--tone-a), var(--tone-b) 45%, var(--tone-c));
  /* A specular top edge, the same glass-rim device as everywhere else on
     the site, layered on top of the tone gradient rather than replacing
     it. These banners are meant to stay boldly coloured per doc tone. */
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
  color: #fff;
}
.doc-banner .doc-icon { color: rgba(255, 255, 255, 0.95); }

.doc-card {
  display: block;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
  overflow: hidden;
  text-decoration: none;
  color: inherit;
  background: var(--surface);
  transition: transform 0.25s var(--ease-out), border-color 0.25s var(--ease);
}
.doc-card:hover { transform: translateY(-3px); border-color: var(--border-strong); }

.doc-card-banner {
  height: 130px;
  display: grid;
  place-items: center;
}
.doc-card-banner .doc-icon { width: 30px; height: 30px; }

.doc-card-body { padding: 18px 20px 20px; }
.doc-card-body h3 { font-size: var(--step-0); margin-bottom: 0.4em; }
.doc-card-body p { color: var(--text-soft); font-size: var(--step-0s); margin: 0; line-height: 1.5; }

/* Apple's badge is fixed artwork (see views/partials/app-store-badge.ejs),
   so the only styling this site actually owns is placement: the same
   margin-top: 14px every .btn in a .doc-card-body already opens with, kept
   as its own rule rather than reusing .btn because this is an <img> inside
   an <a>, not a button, and must never pick up .btn's padding or border. */
.app-store-badge {
  display: inline-block;
  margin-top: 14px;
  line-height: 0;
  border-radius: 8px;
  transition: transform 0.2s var(--ease-out);
}
.app-store-badge:hover { transform: translateY(-2px); }
.app-store-badge img { display: block; height: 40px; width: auto; }

.docs-section-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  margin: 44px 0 16px;
}
.docs-section-head:first-child { margin-top: 0; }
.docs-section-head h2 { font-size: var(--step-1); margin: 0; }
.docs-section-head span { color: var(--text-muted); font-size: var(--step--1); white-space: nowrap; }

.doc-page-banner {
  border-radius: var(--radius-lg);
  padding: clamp(28px, 5vw, 40px) 24px;
  text-align: center;
  margin-bottom: 30px;
}
.doc-page-banner .doc-icon { width: 32px; height: 32px; margin-bottom: 14px; }
.doc-page-banner .doc-eyebrow {
  display: block;
  font-size: var(--step--2);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: rgba(255, 255, 255, 0.85);
  margin-bottom: 10px;
}
.doc-page-banner h1 {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-3);
  letter-spacing: -0.01em;
  color: #fff;
  margin-bottom: 0.3em;
}
.doc-page-banner p {
  color: rgba(255, 255, 255, 0.85);
  font-size: var(--step-0);
  max-width: 52ch;
  margin: 0 auto;
  line-height: 1.5;
}

/* ==========================================================================
   Docs layout
   ========================================================================== */

.docs-layout {
  display: grid;
  grid-template-columns: 232px minmax(0, 1fr) 200px;
  gap: clamp(24px, 4vw, 56px);
  align-items: start;
  /* padding-block, not the padding shorthand: these classes are applied to
     the same element as .shell, and the shorthand would reset the inline
     padding that keeps content off the screen edge. */
  padding-block: 40px 88px;
}

.docs-sidebar,
.docs-toc {
  position: sticky;
  top: 86px;
  max-height: calc(100vh - 110px);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
}

.docs-sidebar-heading {
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
  margin: 24px 0 8px;
  font-weight: 700;
}
.docs-sidebar-heading:first-child { margin-top: 0; }

.docs-sidebar ul { list-style: none; margin: 0; padding: 0; }

.docs-sidebar a {
  display: block;
  padding: 6px 12px;
  border-radius: 8px;
  color: var(--text-soft);
  text-decoration: none;
  font-size: var(--step--1);
  border-left: 2px solid transparent;
  transition: color 0.15s var(--ease), background 0.15s var(--ease);
}
.docs-sidebar a:hover { color: var(--text); background: var(--surface); }
/* The current page used to be a flat var(--surface) pill with a violet rule,
   the one nav state on the whole site with no gradient anywhere near it.
   Scrimmed the same way and for the same reason as .theme-toggle-thumb and
   .tag.is-active: the dark overlay is what keeps var(--on-brand) text
   readable against whichever accent the gradient lands on. */
.docs-sidebar a[aria-current='page'] {
  color: var(--on-brand);
  background: linear-gradient(rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.28)), var(--accent-gradient);
  border-left-color: transparent;
  font-weight: 600;
}

.docs-toc-heading {
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
  margin: 0 0 10px;
  font-weight: 700;
}
.docs-toc ul { list-style: none; margin: 0; padding: 0; position: relative; }
/* One marker that slides between entries rather than each entry's own
   left border blinking violet in turn. site.js sets --toc-y/--toc-h from
   the active link's offsetTop/offsetHeight (the list is its offset
   parent, hence position: relative above) and --toc-on to 0 while no
   heading has been reached yet, so the marker sits exactly over the grey
   rail the links already draw and glides along it. */
.docs-toc ul::before {
  content: '';
  position: absolute;
  left: 0;
  top: var(--toc-y, 0px);
  width: 2px;
  height: var(--toc-h, 0px);
  border-radius: 1px;
  background: var(--violet);
  opacity: var(--toc-on, 0);
  transition: top 0.3s var(--ease-out), height 0.3s var(--ease-out), opacity 0.2s var(--ease);
}
.docs-toc a {
  display: block;
  padding: 4px 0 4px 12px;
  border-left: 2px solid var(--border);
  color: var(--text-muted);
  text-decoration: none;
  font-size: var(--step--1);
  line-height: 1.45;
  transition: color 0.15s var(--ease), border-color 0.15s var(--ease);
}
.docs-toc a:hover { color: var(--text); }
/* Colour only: the violet rail segment is the sliding ul::before marker
   above, not this link's own border, or the two would double up. */
.docs-toc a.is-active { color: var(--text); }
.docs-toc .toc-3 { padding-left: 24px; font-size: var(--step--2); }

.doc-header { margin-bottom: 30px; }

.doc-meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px 16px;
  margin-top: -14px;
  margin-bottom: 24px;
  color: var(--text-muted);
  font-size: var(--step--1);
}

.doc-pager {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 14px;
  margin-top: 56px;
  padding-top: 28px;
  border-top: 1px solid var(--border);
}
.doc-pager a,
.post-nav a {
  display: block;
  padding: 16px 18px;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  text-decoration: none;
  color: var(--text);
  /* The same lift as .card and .doc-card: the visitor arrives at the
     bottom of a doc or post from cards that rise on hover, so a pager that
     only recoloured its border sat visibly below the page's own bar. */
  transition: transform 0.25s var(--ease-out), border-color 0.2s var(--ease), background 0.2s var(--ease);
}
.doc-pager a:hover,
.post-nav a:hover { border-color: var(--border-strong); background: var(--surface); transform: translateY(-2px); }
.doc-pager .direction,
.post-nav .direction {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
  margin-bottom: 5px;
  font-weight: 600;
}
.doc-pager .next-link,
.post-nav .next-link { text-align: right; }
.doc-pager .next-link .direction,
.post-nav .next-link .direction { justify-content: flex-end; }

/* A chevron on each direction label, drawn as a mask over currentColor
   so it takes the label's own muted grey and needs no glyph in the
   markup (the views render "Previous"/"Newer" as plain text). It nudges
   3px in its direction of travel on hover, the .text-link arrow's move.
   The mask SVG's own stroke colour is irrelevant, only its alpha is used. */
.doc-pager .direction::before,
.post-nav .direction::before {
  content: '';
  width: 11px;
  height: 11px;
  flex-shrink: 0;
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 5-7 7 7 7'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 5-7 7 7 7'/%3E%3C/svg%3E") center / contain no-repeat;
  transition: transform 0.2s var(--ease);
}
.doc-pager .next-link .direction::before,
.post-nav .next-link .direction::before {
  order: 1;
  transform: scaleX(-1);
}
.doc-pager a:hover .direction::before,
.post-nav a:hover .direction::before { transform: translateX(-3px); }
.doc-pager a.next-link:hover .direction::before,
.post-nav a.next-link:hover .direction::before { transform: scaleX(-1) translateX(-3px); }

.breadcrumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 7px;
  font-size: var(--step--1);
  color: var(--text-muted);
  margin-bottom: 16px;
}
.breadcrumbs a { color: var(--text-muted); text-decoration: none; }
.breadcrumbs a:hover { color: var(--text); }

/* ==========================================================================
   Blog
   ========================================================================== */

.post-list { display: grid; gap: 16px; }

.post-card {
  display: flex;
  gap: 20px;
  align-items: flex-start;
  padding: 26px;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
  background: var(--surface);
  text-decoration: none;
  color: inherit;
  transition: transform 0.25s var(--ease-out), border-color 0.25s var(--ease);
}
.post-card:hover { transform: translateY(-3px); border-color: var(--border-strong); }
.post-card-body { flex: 1; min-width: 0; }

.post-card h3 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-1); letter-spacing: -0.01em; margin-bottom: 0.35em; }
.post-card p { color: var(--text-soft); margin: 0 0 14px; font-size: var(--step-0s); }

/* A small version of .doc-banner's tone gradient, for a list row rather
   than a grid tile: same tone-<x> custom properties, a square badge
   instead of a full-width strip. */
.post-icon {
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  border-radius: 14px;
  flex-shrink: 0;
  color: #fff;
  background: linear-gradient(135deg, var(--tone-a), var(--tone-b) 45%, var(--tone-c));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.post-icon .doc-icon { width: 22px; height: 22px; }
/* Every badge shares one view-transition class so the per-slug names the
   views write inline (view-transition-name: post-<slug>, on the index card
   and again in the post header) can be timed from one rule below, in the
   cross-document view transitions block, without listing the slugs. */
.post-icon { view-transition-class: post-badge; }

.post-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 10px;
  font-size: var(--step--1);
  color: var(--text-muted);
}
.post-meta time { white-space: nowrap; }
/* A middot between facts, the .hero-facts separator, so "Wuji 21 August
   2026 3 min read" stops reading as one run-on line. The tags sit in their
   own .post-meta-tags group (see blog/index.ejs and blog/post.ejs) so the
   dot lands once, before the group, and never between two pills. */
.post-meta > * + *::before {
  content: '\00b7';
  margin-right: 10px;
  opacity: 0.6;
}
.post-meta-tags { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }

.post-featured {
  background: linear-gradient(145deg, color-mix(in srgb, var(--violet) 12%, var(--surface-solid)), var(--surface-solid));
  border-color: color-mix(in srgb, var(--violet) 32%, transparent);
}

.tag {
  display: inline-flex;
  align-items: center;
  padding: 3px 10px;
  border-radius: 999px;
  border: 1px solid var(--border);
  font-size: var(--step--2);
  font-weight: 600;
  color: var(--text-muted);
  text-decoration: none;
  background: var(--surface);
  transition: color 0.15s var(--ease), border-color 0.15s var(--ease);
}
a.tag:hover { color: var(--text); border-color: var(--violet); }
/* Scrimmed the same way and for the same reason as .theme-toggle-thumb
   above, see its comment for the actual per-palette numbers. */
.tag.is-active { background: linear-gradient(rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.28)), var(--accent-gradient); color: var(--on-brand); border-color: transparent; }

.tag-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 28px; }

/* The editorial threshold: badge, title, lede and meta sit above a single
   hairline, and the body starts a clear beat below it, rather than header
   and article sharing one unbroken column. The width is the prose measure
   (66ch, see .prose), and since ch resolves against the element's own
   font-size this block runs at the prose's own step, stated here rather
   than inherited so the hairline stays pinned to the text under it even
   if an ancestor's size ever changes. Every child sets its own size in a
   --step token, so nothing inside actually changes size. */
.post-header {
  max-width: 66ch;
  font-size: var(--step-0);
  margin: 0 auto 40px;
  padding: 40px 0 28px;
  border-bottom: 1px solid var(--border);
}
.post-header .post-icon { margin: 4px 0 22px; }
.post-header h1 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-3); letter-spacing: -0.01em; margin-bottom: 0.35em; }
.post-header .post-lede { font-size: var(--step-1); color: var(--text-soft); line-height: 1.5; margin-bottom: 1em; }

.post-body { margin-inline: auto; }
/* The opening paragraph at the lede size, the same first-paragraph device
   a magazine feature uses to walk the reader in from the standfirst. */
.post-body > p:first-of-type {
  font-size: var(--step-1);
  line-height: 1.5;
  color: var(--text);
}

.post-nav {
  /* The .prose measure, at the prose's own size so ch agrees, see
     .post-header. The titles inside inherit it, which puts them at the
     same size as the body text they sit under. */
  max-width: 66ch;
  font-size: var(--step-0);
  margin: 56px auto 0;
  padding-top: 28px;
  border-top: 1px solid var(--border);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 14px;
}

/* ==========================================================================
   Forms
   ========================================================================== */

.auth-wrap {
  min-height: calc(100vh - 62px);
  display: grid;
  place-items: center;
  padding-block: 48px 72px;
  position: relative;
  overflow: hidden;
}
/* A static, cheap ambient wash, no canvas, no animation, so the glass
   card below actually has something of its own to blur. Auth pages had no
   background treatment at all before this; without it the card's
   backdrop-filter would have nothing behind it to act on.

   Fixed to the viewport, not absolute within .auth-wrap: .auth-wrap also
   carries the site's .shell class, which caps its own box at the
   1200px content width. An absolutely-positioned wash inherited that same
   cap, so on any screen wider than the shell the glow visibly stopped and
   left plain flat background filling the rest of the browser window. A
   fixed position ignores that shell constraint entirely and always covers
   the real viewport, whatever width it actually is. */
.auth-wrap::before,
.error-page-lost::before {
  content: '';
  position: fixed;
  inset: -5%;
  z-index: -1;
  background:
    radial-gradient(45% 45% at 20% 15%, color-mix(in srgb, var(--violet) 30%, transparent), transparent 70%),
    radial-gradient(40% 40% at 85% 85%, color-mix(in srgb, var(--teal) 22%, transparent), transparent 70%);
  filter: blur(60px);
  pointer-events: none;
}
:root[data-theme='light'] .auth-wrap::before,
:root[data-theme='light'] .error-page-lost::before { opacity: 0.6; }

/* Two panels sharing ONE glass surface, not two stacked ones, the
   branding side and the form side are just flex columns with padding, all
   the material (blur/border/shadow) lives on the outer .auth-layout. A
   narrow centred card was all height and no width to work with; this
   actually uses a wide viewport instead of ignoring it. */
/* Single glass card either way. Only the width and column count change
   when a branding panel is actually present. Confirmation-style pages
   (check email, reset password, etc.) use the exact same wrapper with
   just the form side, so there is one glass recipe for every auth screen
   instead of two. */
.auth-layout {
  width: min(430px, 100%);
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  border-radius: var(--radius-xl);
  overflow: hidden;
  background: color-mix(in srgb, var(--bg) 45%, transparent);
  backdrop-filter: saturate(160%) blur(16px);
  -webkit-backdrop-filter: saturate(160%) blur(16px);
  border: 1px solid color-mix(in srgb, #fff 14%, var(--border));
  box-shadow: var(--shadow), inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent);
}
.auth-layout:has(.auth-panel) {
  width: min(940px, 100%);
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
:root[data-theme='light'] .auth-layout {
  border-color: color-mix(in srgb, var(--indigo) 10%, var(--border));
  box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.55);
}

.auth-panel {
  padding: clamp(32px, 4vw, 48px);
  display: flex;
  flex-direction: column;
  justify-content: center;
  background:
    linear-gradient(165deg, color-mix(in srgb, var(--violet) 22%, transparent), transparent 65%),
    color-mix(in srgb, var(--bg) 20%, transparent);
  border-right: 1px solid color-mix(in srgb, #fff 10%, var(--border));
}
/* Icon beside the wordmark, the same lockup the header itself uses --
   sized off the heading's own em rather than stacked above it. That only
   works if this element's own font-size actually matches the h2's, an em
   unit resolves against its own element's font-size, not a sibling's, so
   without this the mark was inheriting the ~16px body size instead of the
   heading's 1.9rem and rendering visibly too small next to it. */
/* 2.4rem on both, the h2 and the lockup wrapper whose em the mark is sized
   off: the panel's italic wordmark and the form's upright h1 used to sit
   side by side at 1.9rem and 1.7rem, near enough the same size to compete.
   The panel is the poster now and the form the utility, see .auth-card h1
   below. */
.auth-panel-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; font-size: 2.4rem; }
.auth-panel-brand .mark { width: auto; height: 0.72em; flex-shrink: 0; }
.auth-panel h2 {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 500;
  font-size: 2.4rem;
  letter-spacing: -0.02em;
  margin: 0;
  margin-bottom: 0.3em;
}
.auth-panel p { color: var(--text-soft); font-size: var(--step-0s); line-height: 1.6; max-width: 32ch; }
.auth-benefits { list-style: none; margin: 22px 0 0; padding: 0; display: grid; gap: 12px; }
.auth-benefits li { display: flex; align-items: flex-start; gap: 10px; font-size: var(--step--1); color: var(--text-soft); }
.auth-benefits svg {
  width: 17px;
  height: 17px;
  flex-shrink: 0;
  margin-top: 1px;
  color: var(--teal);
}
/* The same drawn tick as the home page checklist, on page load rather
   than on scroll since the auth panel is always above the fold: the three
   ticks draw in one after another a beat after the panel settles. */
.has-js .auth-benefits svg path {
  stroke-dasharray: 24;
  stroke-dashoffset: 24;
  animation: tick-draw 0.35s var(--ease-out) 0.45s forwards;
}
.has-js .auth-benefits li:nth-child(2) svg path { animation-delay: 0.57s; }
.has-js .auth-benefits li:nth-child(3) svg path { animation-delay: 0.69s; }
@keyframes tick-draw {
  to { stroke-dashoffset: 0; }
}

.auth-card {
  padding: clamp(32px, 4vw, 48px);
}
/* Visible by default. This is the only mark on solo confirmation pages
   (no branding panel exists there at all). Hidden specifically when a
   panel sibling IS present (signin/signup), since that panel already
   carries the mark, then brought back once the panel itself disappears at
   the same 860px breakpoint, so the form is never markless. */
.auth-card .mark { width: 32px; margin-bottom: 16px; }
.auth-layout:has(.auth-panel) .auth-card .mark { display: none; }
@media (max-width: 860px) {
  .auth-layout:has(.auth-panel) .auth-card .mark { display: block; }
}
/* --step-2 rather than a fixed 1.7rem: the same scale step every other
   utility heading on the site uses, clearly a size below the panel's
   poster h2 above. The eyebrow above it is the site's ordinary one; auth
   pages were the only ones without it. */
.auth-card h1 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-2); letter-spacing: -0.01em; margin-bottom: 0.35em; }
.auth-card .eyebrow { margin-bottom: 10px; }
.auth-card .auth-sub { color: var(--text-muted); font-size: var(--step-0s); margin-bottom: 26px; }

/* 860px, not 760px: an iPad in portrait is 768px wide, and the two-column
   layout was still trying to fit there, squeezing both the branding panel
   and the form into ~380px each rather than collapsing to the single
   column it actually has room to show comfortably. Matches the same
   860px breakpoint the header's own nav collapse already uses. */
/* The plain .auth-layout selector alone loses to .auth-layout:has(.auth-panel)
   above on specificity (0,1,0 against 0,2,0), even though .auth-panel is
   display:none at this width, since :has() matches on the panel's presence
   in the DOM rather than its visibility. That left signin/signup stuck in
   the two-column grid here, an invisible phantom second column still eating
   half the width, while forgot-password (no .auth-panel at all, so the
   :has() rule never matches it in the first place) was never affected.
   Repeating the same :has(.auth-panel) selector here, the same fix already
   used just below for .auth-card .mark, matches that specificity exactly and
   wins on source order instead, collapsing every auth page to one real
   column at this width regardless of whether a branding panel exists. */
@media (max-width: 860px) {
  .auth-layout,
  .auth-layout:has(.auth-panel) { grid-template-columns: 1fr; width: min(430px, 100%); }
  .auth-panel { display: none; }
}

.auth-alt {
  margin-top: 22px;
  padding-top: 20px;
  border-top: 1px solid var(--border);
  text-align: center;
  font-size: var(--step--1);
  color: var(--text-muted);
}

.field { margin-bottom: 17px; }

.field label {
  display: block;
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--text);
  margin-bottom: 6px;
}

/* ---- Username edit gate -------------------------------------------------
   Collapsed by default (see views/account/index.ejs's own comment on why):
   a plain row showing the current handle and an Edit button until tapped,
   the actual input only mounted in the DOM once <summary> is clicked open. */
.username-edit summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  cursor: pointer;
  list-style: none;
}
.username-edit summary::-webkit-details-marker { display: none; }
.username-edit-current {
  font-family: var(--font-display);
  font-size: var(--step-0l);
  color: var(--text);
}
.username-edit-body { margin-top: 12px; }
.username-edit[open] summary { margin-bottom: 4px; }

/* .hint is the same voice outside a form: the note under a download
   button, the "check your spam folder" line, the sentence under a
   preference checkbox. One rule, so every quiet explanatory line on the
   site is the same size and colour rather than an inline style each. */
.field-hint, .hint { font-size: var(--step--1); color: var(--text-muted); margin-top: 6px; line-height: 1.45; }
.checkbox .hint { display: block; margin-top: 2px; }
/* Restated at the specificity of .card p and .doc-card-body p, which
   would otherwise pull a hint under a download button back up to
   card-copy size and colour. */
.card .hint, .doc-card-body .hint { font-size: var(--step--1); color: var(--text-muted); }

.input, .textarea, .select {
  width: 100%;
  padding: 11px 13px;
  border-radius: 11px;
  border: 1px solid color-mix(in srgb, #fff 14%, var(--border-strong));
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  backdrop-filter: saturate(150%) blur(10px);
  -webkit-backdrop-filter: saturate(150%) blur(10px);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent);
  color: var(--text);
  font: inherit;
  font-size: var(--step-0s);
  transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease), background 0.15s var(--ease);
}
.input::placeholder, .textarea::placeholder { color: var(--text-muted); }
/* Plain var(--violet) only reaches 2.93:1 against dark theme's --bg
   (#0a0b11), under the 3:1 non-text minimum a focus indicator has to clear
   -- and outline:none plus a same-colour border was the whole indicator, so
   the ring itself was failing, not just its edge against the page. A real
   outline restores a second, independently-coloured edge (offset out onto
   the page background, not just repeating the border), and in dark theme
   specifically that outline is mixed toward white first: computed against
   --bg, plain --violet stays at 2.93:1 no matter how it's drawn, so the
   colour itself needed brightening, not just the technique. 20% white
   lands at 4.37:1, comfortable margin over the 3:1 floor without reading as
   a different colour from the rest of the violet UI. Light theme's own
   --bg (#f6f7fc) already puts plain --violet at 6.28:1, so it keeps the
   border-only treatment with no outline needed. */
.input:focus, .textarea:focus, .select:focus {
  outline: none;
  border-color: var(--violet);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent), 0 0 0 3px color-mix(in srgb, var(--violet) 22%, transparent);
}
:root:not([data-theme='light']) .input:focus,
:root:not([data-theme='light']) .textarea:focus,
:root:not([data-theme='light']) .select:focus {
  outline: 2px solid color-mix(in srgb, var(--violet) 80%, white);
  outline-offset: 2px;
}
:root[data-theme='light'] .input,
:root[data-theme='light'] .textarea,
:root[data-theme='light'] .select {
  border-color: color-mix(in srgb, var(--indigo) 10%, var(--border-strong));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
.textarea { resize: vertical; min-height: 92px; line-height: 1.55; }

.field.has-error .input,
.field.has-error .textarea { border-color: #e0576d; }

/* One error ink for the whole form vocabulary, as a token rather than a
   literal repeated per rule: .field-error is no longer the only thing that
   needs it, since the banner uploader's status line reports its failures in
   the same colour, and two copies of a colour are two things to keep in
   step. */
:root { --field-error-ink: #ef7f90; }

.field-error {
  display: flex;
  align-items: flex-start;
  gap: 6px;
  color: var(--field-error-ink);
  font-size: var(--step--1);
  margin-top: 6px;
  line-height: 1.45;
}
/* The glyph the gap above was always reserved for, now actually rendered
   by the auth views (see partials/field-error-icon.ejs). */
.field-error svg { width: 14px; height: 14px; flex-shrink: 0; margin-top: 2px; }
:root[data-theme='light'] { --field-error-ink: #c32e46; }

.checkbox {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  cursor: pointer;
  font-size: var(--step-0s);
  color: var(--text-soft);
  line-height: 1.5;
}
.checkbox input {
  margin: 3px 0 0;
  width: 17px;
  height: 17px;
  accent-color: var(--violet);
  flex-shrink: 0;
  cursor: pointer;
}

/* Auth fields: a leading glyph (mail, lock, user, see
   partials/field-glyph.ejs) sitting inside the input's own padding, muted
   until the field takes focus and then in the same violet as the focus
   ring, so the glyph reads as part of the control rather than an icon
   parked next to it. */
.field-icon { position: relative; }
.field-icon-glyph {
  position: absolute;
  left: 13px;
  top: 50%;
  width: 16px;
  height: 16px;
  transform: translateY(-50%);
  color: var(--text-muted);
  pointer-events: none;
  transition: color 0.15s var(--ease);
}
.field-icon .input { padding-left: 38px; }
.field-icon:focus-within .field-icon-glyph { color: var(--violet); }

/* The password eye (partials/field-eye.ejs, toggled in site.js). A real
   button inside the field's own box, sized to a comfortable 32px target,
   with the two glyphs swapped on aria-pressed so the state is carried by
   the one attribute assistive tech already reads. */
.field-icon.has-eye .input { padding-right: 44px; }
.field-eye {
  position: absolute;
  right: 5px;
  top: 50%;
  width: 32px;
  height: 32px;
  transform: translateY(-50%);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition: color 0.15s var(--ease), background 0.15s var(--ease);
}
.field-eye:hover { color: var(--text); background: var(--surface); }
/* Same brightened ring the inputs use in dark theme (see .input:focus
   above for why plain --violet falls short of 3:1 there). */
.field-eye:focus-visible { outline: 2px solid color-mix(in srgb, var(--violet) 80%, white); outline-offset: 1px; color: var(--text); }
:root[data-theme='light'] .field-eye:focus-visible { outline-color: var(--violet); }
.field-eye svg { width: 18px; height: 18px; }
.field-eye .eye-shut { display: none; }
.field-eye[aria-pressed='true'] .eye-open { display: none; }
.field-eye[aria-pressed='true'] .eye-shut { display: block; }

/* ---- Sign-in identifier toggle -------------------------------------------
   One field, two labels. data-identifier-mode lives on .field itself so a
   single attribute drives every dependent bit: which of the two <span>s
   inside the <label> shows, which of the two glyph spans shows, all pure
   CSS off the one attribute site.js sets on click. The input's own `type`
   is the one thing CSS cannot reach and site.js sets that directly,
   mirroring exactly how .field-eye flips a password field's type. */
.identifier-mode-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}
.identifier-mode-row label { margin-bottom: 0; }

[data-identifier-mode='email'] .identifier-label-username,
[data-identifier-mode='username'] .identifier-label-email,
[data-identifier-mode='email'] .identifier-glyph-username,
[data-identifier-mode='username'] .identifier-glyph-email {
  display: none;
}

/* Validity feedback: a field the visitor has finished with and got right
   takes a quiet teal edge (and its glyph follows), :user-valid rather than
   :valid so an untouched required field is not already "wrong" or "right"
   on first paint. Only while unfocused, the violet focus state above wins
   while typing. .field.has-error's own red edge is more specific and keeps
   winning for a server-rejected value. */
.input:user-valid:not(:focus) { border-color: color-mix(in srgb, var(--teal) 55%, var(--border-strong)); }
.field-icon:has(.input:user-valid:not(:focus)) .field-icon-glyph { color: var(--teal); }

/* The hCaptcha widget is a fixed 303px iframe in its own visual language;
   this frame gives it the input's radius, the same glass rim and edge, and
   a little breathing room so it sits in the form as one more field rather
   than a foreign object dropped into it. The widget itself is rendered by
   site.js with whichever theme is live (light or dark), which is why the
   frame does not need to fake one. */
.captcha-frame {
  display: flex;
  justify-content: center;
  padding: 6px;
  border-radius: 11px;
  border: 1px solid color-mix(in srgb, #fff 14%, var(--border-strong));
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  backdrop-filter: saturate(150%) blur(10px);
  -webkit-backdrop-filter: saturate(150%) blur(10px);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent);
  min-height: 90px;
  overflow: hidden;
}
:root[data-theme='light'] .captcha-frame {
  border-color: color-mix(in srgb, var(--indigo) 10%, var(--border-strong));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
.captcha-frame .h-captcha { display: block; line-height: 0; max-width: 100%; }
.captcha-frame iframe { border-radius: 6px; max-width: 100%; }

/* Submit feedback while a slow captcha check or sign-in round trip runs
   (site.js adds is-busy on submit): the label goes transparent so the
   button keeps its exact size, and a conic ring spins where it was. On
   ::before, since .btn-primary already spends ::after on its hover
   sweep. --on-brand is the ring colour on the primary gradient; a
   secondary button would want currentColor, but only primaries submit
   these forms. Reduced motion collapses the spin to one static ring
   through the global duration rule, which still says "working". */
.btn.is-busy {
  color: transparent;
  pointer-events: none;
}
.btn.is-busy::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border-radius: 50%;
  background: conic-gradient(from 0deg, transparent 0 22%, var(--on-brand) 100%);
  mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px));
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px));
  animation: btn-spin 0.8s linear infinite;
}
/* The case the rule above says it is not written for, now that it exists. The
   banner uploader's "Upload an image" is a secondary button and it goes busy
   for the length of a real upload, where --on-brand (white, the ink for the
   primary gradient) would be a white ring on a pale surface in light theme:
   invisible exactly when somebody is waiting to be told something is
   happening. --text is the ink that button's own label was using, so the ring
   reads on whatever surface the theme gives it. Not currentColor, which
   .btn.is-busy has already set to transparent. */
.btn-secondary.is-busy::before,
.btn-ghost.is-busy::before {
  background: conic-gradient(from 0deg, transparent 0 22%, var(--text) 100%);
}
@keyframes btn-spin {
  to { transform: rotate(360deg); }
}

/* ==========================================================================
   Flash and callouts
   ========================================================================== */

.flash {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  padding: 13px 16px;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: var(--surface-solid);
  font-size: var(--step-0s);
  margin-bottom: 22px;
  line-height: 1.5;
}
.flash svg { width: 18px; height: 18px; flex-shrink: 0; margin-top: 2px; }
.flash-success { border-color: rgba(45, 190, 130, 0.5); color: #6fdcaa; }
.flash-error { border-color: rgba(224, 87, 109, 0.5); color: #ef8b9a; }
.flash-info { border-color: rgba(61, 111, 224, 0.5); color: #8fb4ff; }
:root[data-theme='light'] .flash-success { color: #12744c; }
:root[data-theme='light'] .flash-error { color: #b62b41; }
:root[data-theme='light'] .flash-info { color: #1f52ba; }

.banner {
  padding: 15px 18px;
  border-radius: var(--radius);
  border: 1px solid color-mix(in srgb, #fff 14%, var(--border-strong));
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  backdrop-filter: saturate(160%) blur(12px);
  -webkit-backdrop-filter: saturate(160%) blur(12px);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent);
  margin-bottom: 22px;
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
  font-size: var(--step-0s);
}
.banner p { margin: 0; flex: 1 1 260px; color: var(--text-soft); }
.banner-warn { border-color: rgba(240, 178, 99, 0.45); }
:root[data-theme='light'] .banner {
  border-color: color-mix(in srgb, var(--indigo) 10%, var(--border-strong));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
}

.note {
  padding: 16px 20px;
  border-radius: var(--radius);
  border: 1px solid color-mix(in srgb, #fff 12%, var(--border));
  border-left: 3px solid var(--teal);
  background: color-mix(in srgb, var(--bg) 35%, transparent);
  backdrop-filter: saturate(150%) blur(10px);
  -webkit-backdrop-filter: saturate(150%) blur(10px);
  margin: 1.6em 0;
  font-size: var(--step-0s);
  color: var(--text-soft);
}
.note strong { color: var(--text); }
:root[data-theme='light'] .note { border-color: color-mix(in srgb, var(--indigo) 8%, var(--border)); }

/* ==========================================================================
   Account
   ========================================================================== */

.account-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 20px;
  padding-block: 40px 80px;
  max-width: 760px;
  margin-inline: auto;
}

/* This used to be a flat var(--surface) row, the only identity block on the
   site with no accent, no wash, nothing to say whose account it was beyond
   the initial in the corner. The public profile card's banner already
   answers "what does a personalised, premium identity block look like
   here"; this is that same language at settings-page scale, a wash rather
   than a full band, since a settings header does not need its own hero. */
.account-header {
  position: relative;
  display: flex;
  align-items: center;
  gap: 18px;
  padding: 26px;
  border-radius: var(--radius-lg);
  background: linear-gradient(145deg, color-mix(in srgb, var(--violet) 16%, var(--surface-solid)), var(--surface-solid) 65%);
  border: 1px solid color-mix(in srgb, var(--violet) 26%, var(--border));
  flex-wrap: wrap;
}

.avatar {
  width: 60px;
  height: 60px;
  border-radius: 18px;
  display: grid;
  place-items: center;
  /* Scrimmed the same way and for the same reason as .theme-toggle-thumb
     above, see its comment for the actual per-palette numbers. */
  background: linear-gradient(rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.28)), var(--accent-gradient);
  color: var(--on-brand);
  font-size: 1.5rem;
  font-weight: 700;
  flex-shrink: 0;
  box-shadow: var(--glow);
}
/* The real picture rather than the letter tile, once there is one: object-fit
   is the only thing an <img> needs on top of .avatar's existing box, ring and
   radius. */
.avatar-photo { object-fit: cover; }

.account-header h1 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-2); letter-spacing: -0.01em; margin-bottom: 0.15em; }
.account-header p { margin: 0; color: var(--text-muted); font-size: var(--step-0s); }

.panel {
  padding: 26px;
  border-radius: var(--radius-lg);
  background: color-mix(in srgb, var(--bg) 35%, transparent);
  backdrop-filter: saturate(150%) blur(12px);
  -webkit-backdrop-filter: saturate(150%) blur(12px);
  border: 1px solid color-mix(in srgb, #fff 14%, var(--border));
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent);
}
.panel h2 { font-size: var(--step-0l); margin-bottom: 0.35em; }
.panel > p:first-of-type { color: var(--text-muted); font-size: var(--step-0s); margin-bottom: 20px; }
.panel-danger { border-color: rgba(224, 87, 109, 0.35); }
:root[data-theme='light'] .panel {
  border-color: color-mix(in srgb, var(--indigo) 10%, var(--border));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
}

.pill {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 10px;
  border-radius: 999px;
  font-size: var(--step--2);
  font-weight: 600;
  border: 1px solid var(--border);
  color: var(--text-muted);
}
/* Were among the last fixed hexes on the site (a mint green and an amber
   from before the palette picker existed). Mixed from --teal and --violet
   toward --text instead: --text is near-white in dark theme and near-black
   in light, so one rule per pill lightens or darkens its accent for the
   theme it is on, and both follow the active palette. */
.pill-verified {
  color: color-mix(in srgb, var(--teal) 65%, var(--text));
  border-color: color-mix(in srgb, var(--teal) 45%, transparent);
}
.pill-pending {
  color: color-mix(in srgb, var(--violet) 60%, var(--text));
  border-color: color-mix(in srgb, var(--violet) 45%, transparent);
}
/* Less accent in the mix on a light ground: Mono's teal role is white
   and Reverse's is near it, so a 65% share there would leave a pale grey
   label on a white page. 45% keeps every palette's pill at a readable
   depth in light theme. */
:root[data-theme='light'] .pill-verified { color: color-mix(in srgb, var(--teal) 45%, var(--text)); }
:root[data-theme='light'] .pill-pending { color: color-mix(in srgb, var(--violet) 45%, var(--text)); }
/* "Coming soon" carried the same visual weight as the neutral "iOS 17+"
   pills beside it. A 6px dot that breathes, slowly, is the difference
   between a label and a status: something is pending here. Reduced
   motion holds it at full opacity. */
.pill-pending::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
  animation: pill-breathe 2.6s ease-in-out infinite;
}
@keyframes pill-breathe {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.45; transform: scale(0.8); }
}

.definition-list { display: grid; gap: 13px; margin: 0; }
.definition-list div { display: flex; justify-content: space-between; gap: 16px; font-size: var(--step-0s); flex-wrap: wrap; }
.definition-list dt { color: var(--text-muted); margin: 0; }
.definition-list dd { margin: 0; color: var(--text); font-weight: 500; text-align: right; }

/* ==========================================================================
   Sources
   ========================================================================== */

.repo-box {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 16px 18px;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: var(--code-bg);
  margin: 22px 0;
  flex-wrap: wrap;
  /* The site's glass rim, a hairline of light along the top edge, the
     same one .panel and .btn-secondary carry: this box used to be the one
     raised surface on /download and /sources with no rim at all. */
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 10%, transparent);
}
:root[data-theme='light'] .repo-box { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); }
.repo-url {
  font-family: var(--mono);
  font-size: var(--step-0s);
  color: var(--text);
  flex: 1 1 240px;
  word-break: break-all;
}

/* A source is a post-card-shaped row: tone badge on the left (picked by
   medium in sources.ejs), body on the right, and the .card hover recipe
   (lift, stronger edge, shadow, the accent hairline along the top) so it
   stops being the one card on the site that does nothing under the
   cursor. */
.source-card {
  display: flex;
  gap: 20px;
  align-items: flex-start;
  position: relative;
  overflow: hidden;
  padding: 24px;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
  background: var(--surface);
  transition: transform 0.25s var(--ease-out), border-color 0.25s var(--ease), box-shadow 0.25s var(--ease);
}
.source-card::before {
  content: '';
  position: absolute;
  inset: 0 0 auto 0;
  height: 1px;
  background: var(--accent-gradient);
  opacity: 0;
  transition: opacity 0.25s var(--ease);
}
.source-card:hover {
  transform: translateY(-3px);
  border-color: var(--border-strong);
  box-shadow: 0 24px 48px -28px rgba(0, 0, 0, 0.5);
}
.source-card:hover::before { opacity: 1; }
.source-card-body { flex: 1; min-width: 0; }
.source-card header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
/* The serif, at the blog index's title size scaled to a list row, so
   sources, posts and search results share one title voice. */
.source-card h3 { margin: 0 4px 0 0; font-family: var(--font-display); font-weight: 500; font-size: var(--step-1); letter-spacing: -0.01em; }
.source-card p { color: var(--text-soft); font-size: var(--step-0s); margin-bottom: 16px; }
/* A version number is a code-shaped thing; the mono face says so where
   the body face just looked like a word that happened to start with v. */
.pill-version { font-family: var(--mono); font-weight: 500; letter-spacing: 0.02em; font-size: var(--step--2); }

/* Capability chips. The 999px radius the rest of the site's pills use
   (the old 6px box was a third radius vocabulary on one page), a teal tick
   so each one reads as "this source can", and a teal-tinted edge that
   stays a tint in every palette. A list, not a row of spans, so a screen
   reader gets a count. */
.cap-row { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; padding: 0; margin: 0; }
.cap {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: var(--step--2);
  padding: 3px 10px 3px 8px;
  border-radius: 999px;
  background: var(--surface-solid);
  border: 1px solid color-mix(in srgb, var(--teal) 30%, var(--border));
  color: var(--text-soft);
  font-weight: 500;
}
.cap svg { width: 12px; height: 12px; color: var(--teal); flex-shrink: 0; }

/* The two closing links as .card-links, the way every other card on the
   site ends, instead of two underlined anchors in a sentence. */
.source-links { display: flex; flex-wrap: wrap; gap: 0 22px; }
.source-links .card-link { margin-top: 12px; }
.source-links .card-link:hover svg,
.source-links .card-link:focus-visible svg { transform: translateX(3px); }
/* On a phone the badge beside the body left the body 230px wide, with
   the chips wrapping two to a line; stacked, the card reads top to bottom
   like the doc cards do at this width. */
@media (max-width: 560px) {
  .source-card { flex-direction: column; gap: 14px; }
}

/* ==========================================================================
   Search page
   ========================================================================== */

.search-page { padding-block: 56px 80px; max-width: 780px; margin-inline: auto; }
.search-page-input {
  width: 100%;
  padding: 15px 18px;
  font-size: var(--step-0);
  border-radius: var(--radius);
  border: 1px solid color-mix(in srgb, #fff 14%, var(--border-strong));
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  backdrop-filter: saturate(160%) blur(14px);
  -webkit-backdrop-filter: saturate(160%) blur(14px);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 20%, transparent);
  color: var(--text);
  font-family: inherit;
  margin-bottom: 26px;
  transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease);
}
.search-page-input:focus {
  outline: none;
  border-color: var(--violet);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 20%, transparent), 0 0 0 3px color-mix(in srgb, var(--violet) 22%, transparent);
}
:root[data-theme='light'] .search-page-input { border-color: color-mix(in srgb, var(--indigo) 10%, var(--border-strong)); }

/* "6 results for "manga"": the number in the display serif, the query in
   the italic, so the line is the page's caption rather than a status
   string. Inline, not flex, so a long query wraps like prose. */
.search-count {
  margin: 0 0 22px;
  color: var(--text-muted);
  font-size: var(--step-0s);
  line-height: 1.4;
}
.search-count strong {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-2);
  line-height: 1;
  letter-spacing: -0.01em;
  color: var(--text);
  margin-right: 0.1em;
}
.search-count em {
  font-family: var(--font-display);
  font-style: italic;
  font-size: 1.18em;
  color: var(--text-soft);
}

.result-list { display: grid; gap: 12px; }

/* The .card recipe (lift, stronger edge, shadow, the accent hairline along
   the top) rather than a bare border-colour change: a visitor arrives here
   from doc cards and post cards that all lift, and these are the same
   kind of thing. */
.result-item {
  display: block;
  position: relative;
  overflow: hidden;
  padding: 18px 20px;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--surface);
  text-decoration: none;
  color: inherit;
  transition: transform 0.25s var(--ease-out), border-color 0.25s var(--ease), box-shadow 0.25s var(--ease);
}
.result-item::before {
  content: '';
  position: absolute;
  inset: 0 0 auto 0;
  height: 1px;
  background: var(--accent-gradient);
  opacity: 0;
  transition: opacity 0.25s var(--ease);
}
.result-item:hover {
  transform: translateY(-3px);
  border-color: var(--border-strong);
  box-shadow: 0 24px 48px -28px rgba(0, 0, 0, 0.5);
}
.result-item:hover::before { opacity: 1; }
.result-item .search-result-kind { display: inline-block; margin-bottom: 8px; }
/* The serif at the size the doc cards and the blog index already use for
   a title, so the three listings share one title voice. */
.result-item h3 {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-1);
  letter-spacing: -0.01em;
  margin-bottom: 4px;
}
.result-item p { font-size: var(--step-0s); color: var(--text-muted); margin: 0; line-height: 1.55; }

/* ==========================================================================
   CTA and footer
   ========================================================================== */

.cta {
  position: relative;
  padding: clamp(40px, 6vw, 66px);
  border-radius: var(--radius-xl);
  overflow: hidden;
  text-align: center;
  border: 1px solid var(--border-strong);
  background: var(--surface-solid);
  isolation: isolate;
  /* The specular rim .doc-banner and .btn-secondary already carry. The CTA
     is the largest single surface on the page after the hero, and it was
     the one with no rim, no grain and no motion: a flat panel next to
     materials. */
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent);
}
:root[data-theme='light'] .cta { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); }
.cta::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  opacity: 0.5;
  /* The two washes drift, very slowly, the way the hero's blobs do: a
     36s cycle is long enough that nobody watches it move, they only
     notice the panel is not quite still. Scaled up a touch so the drift
     never shows an edge inside the clipped box. Reduced motion holds the
     first frame. */
  animation: cta-drift 36s ease-in-out infinite alternate;
  /* Was Boundless's own --violet and --teal hardcoded as rgba(), from
     before the colour-theme picker existed: color-mix follows whichever
     palette is actually active instead of always washing this section in
     Boundless blue-violet-teal regardless of the chosen theme. */
  background:
    radial-gradient(52% 70% at 22% 0%, color-mix(in srgb, var(--violet) 55%, transparent), transparent 68%),
    radial-gradient(48% 66% at 80% 100%, color-mix(in srgb, var(--teal) 42%, transparent), transparent 68%);
}
@keyframes cta-drift {
  from { transform: translate(0, 0) scale(1.08); }
  to { transform: translate(4%, -4%) scale(1.08); }
}
/* The same grain body::before lays over the whole page, but the body's
   copy sits at z-index max above everything and blends with the page's
   flat ground; a second, local pass inside the panel's own stacking
   context is what gives the washes tooth, so the CTA reads as paper with
   light on it rather than a gradient in a box. */
.cta::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0.035;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
:root[data-theme='light'] .cta::after { opacity: 0.02; mix-blend-mode: multiply; }
/* The mark in the panel's corner at a whisper, the "say the name at the
   bookends" idea the footer comment describes, applied to the page's
   closing panel as well as its final strip. Decorative, so hidden from
   assistive tech in the markup, and kept out of the text column. */
.cta-mark {
  position: absolute;
  right: clamp(18px, 3vw, 30px);
  bottom: clamp(18px, 3vw, 30px);
  z-index: -1;
  opacity: 0.08;
  pointer-events: none;
}
.cta-mark .mark { width: 44px; }
.cta h2 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-3); letter-spacing: -0.01em; margin-bottom: 0.35em; }
.cta p { color: var(--text-soft); font-size: var(--step-1); max-width: 46ch; margin: 0 auto 1.5em; }
.cta .hero-actions { justify-content: center; margin-bottom: 0; }

/* The whole footer sits on the same four-stop gradient as the app icon
   itself, not just a hairline or a faint wash, so its own text can't rely
   on the theme's ordinary (dark-on-light or light-on-dark) tokens, which
   would go illegible or vanish outright against a fixed, saturated
   backdrop that no longer tracks the site's light/dark theme. Everything
   under .site-footer is white at a few fixed opacities instead. */
.site-footer {
  position: relative;
  isolation: isolate;
  padding: 52px 0 34px;
  margin-top: 40px;
  /* Same scrim, same reasoning, as .site-header::before above, see its
     comment for the actual per-palette contrast numbers this was tuned
     against. */
  background: linear-gradient(rgba(0, 0, 0, 0.48), rgba(0, 0, 0, 0.48)), var(--brand-gradient);
}

.footer-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
  gap: 34px;
  margin-bottom: 40px;
}


.footer-heading {
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--on-brand-muted);
  margin: 0 0 13px;
  font-weight: 700;
}
.footer-col ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 9px; }
.footer-col a {
  color: var(--on-brand-soft);
  text-decoration: none;
  font-size: var(--step--1);
  padding-bottom: 2px;
  /* Solid white, not the usual --accent-gradient sweep: a colour-on-colour
     underline would barely register against a background that already
     spans the same hues. */
  background-image: linear-gradient(0deg, #fff, #fff);
  background-repeat: no-repeat;
  background-position: left bottom;
  background-size: 0% 1px;
  transition: color 0.15s var(--ease), background-size 0.25s var(--ease-out);
}
.footer-col a:hover,
.footer-col a:focus-visible { color: var(--on-brand); background-size: 100% 1px; }
/* The sign-out button's own .btn-ghost override lives with the rest of the
   button rules, alongside .site-header's identical need, see .btn-ghost. */

.footer-bottom {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  padding-top: 26px;
  border-top: 1px solid rgba(255, 255, 255, 0.18);
  color: var(--on-brand-muted);
  font-size: var(--step--1);
}

/* The name of the thing, said once at full size before the page runs out --
   full-bleed rather than boxed to the shell, since the point is scale. */
.footer-wordmark {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(8px, 1.5vw, 20px);
  overflow: hidden;
  text-align: center;
  line-height: 0.86;
  padding: 8px 4vw 0;
  margin-top: 28px;
  /* Set here, not just on the span, so the mark can size itself off the
     exact same value via em units and the two always move together. */
  font-size: clamp(3.4rem, 13vw, 11.5rem);
}
/* Sized off the wordmark's own font-size (em), not an independent clamp --
   a wide-and-short viewBox mark sized to "look about right" in pixels
   never actually tracks the glyphs' own cap-height the way em units do. A
   thin white ring plus a dark contact shadow is what keeps the badge a
   legible, separate shape now that the footer sits on this same gradient --
   without it the badge's own gradient fill would blend straight into the
   backdrop behind it. */
.footer-wordmark-mark {
  height: 0.68em;
  width: auto;
  flex-shrink: 0;
  border-radius: 22%;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.35), 0 10px 26px rgba(0, 0, 0, 0.35);
}
/* The footer's editorial sign-off keeps the italic serif flourish, now in
   plain white rather than a gradient clip, the footer itself is the
   gradient now, and gradient text over its own gradient background would
   read as barely-there instead of as the sign-off it is meant to be. */
.footer-wordmark span {
  display: inline-block;
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 500;
  letter-spacing: -0.03em;
  color: #fff;
  text-shadow: 0 4px 18px rgba(0, 0, 0, 0.32), 0 1px 3px rgba(0, 0, 0, 0.4);
}
/* The hero repeats the same gradient-text device (colour, halo, drift) but
   in the modern upright sans that pairs with the header lockup, since this
   is the app's full name standing in for a proper logotype, not the
   footer's editorial sign-off. */
.hero-wordmark {
  display: inline-block;
  font-family: var(--font-modern);
  font-style: normal;
  font-weight: 700;
  letter-spacing: -0.02em;
  background: var(--mark-gradient);
  /* Fixed at 100%, not animated at 200% like the footer's repeat: the mark
     itself never drifts, its gradient is one static diagonal, so the one
     place this text is meant to look identical to the mark has to render
     that exact same fixed gradient too, not a moving slice of it that only
     matches at certain phases and looks duller at others. */
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* A halo bound to the letterforms themselves, not a separate shape behind
     them the way the old backing vignette was. Stacking several 0-offset
     layers here compounded into an actual dark fill rather than an edge
     glow at this font-weight/size, one layer, sized for how big this
     text actually is (the header's own version of this is the same idea
     at a fraction of the blur radius, because it is a fraction of the
     size). Dark theme only needs this at all; light theme's override
     further down is lighter, matching what already worked there. A second,
     tighter layer sits right on the letterforms rather than replacing the
     original: some palettes (Mono most of all, whose gradient runs through
     a mid grey into a literal white --teal) put the tail of this gradient
     close enough to white that the single soft layer alone stopped reading
     as a shadow at all against it. The added layer is a near-zero offset
     with a small blur, so it reads as edge contrast rather than the same
     dark-fill problem the single-layer rewrite above was written to avoid;
     the original wide layer stays for the soft depth it was already doing
     for every other palette.

     drop-shadow(), not text-shadow, and that is the whole reason this text
     stopped looking blurry. text-shadow paints into the glyph's own layer,
     underneath the fill, which works fine for ordinary text, but this
     text has no fill: background-clip plus color: transparent means the
     shadow shows straight through the letterforms it is supposed to be
     sitting behind, so every glyph carried a soft dark copy of itself
     inside its own gradient. drop-shadow is a filter, applied to the
     element after it has already been rendered and clipped, so it shadows
     the finished gradient-in-glyph-shape instead of bleeding through it.

     Both layers are deliberately wide. The tight 2px layer this carried at
     first is gone: a small-radius drop-shadow on a glyph traces the glyph,
     so it landed as a hard dark rim around every letter and read as a cheap
     emboss rather than as depth. Under text-shadow that tight layer was
     doing real work (it was the only thing giving the letterforms an edge
     against a bright patch of the shader), but it is redundant now that the
     wide layer actually sits behind the text instead of through it. So:
     one wide layer for depth, one merely diffuse layer for contact, and no
     radius small enough to draw an outline. */
  filter: drop-shadow(0 4px 20px rgba(0, 0, 0, 0.34)) drop-shadow(0 1px 7px rgba(0, 0, 0, 0.28));
  animation: hero-wordmark-in 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
:root[data-theme='light'] .hero-wordmark {
  filter: drop-shadow(0 3px 14px rgba(0, 0, 0, 0.18));
}
@keyframes hero-wordmark-in {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}

.footer-tagline {
  text-align: center;
  margin: 4px 0 40px;
  color: var(--on-brand-muted);
  font-size: var(--step-0s);
}
.footer-tagline em {
  font-style: italic;
  font-family: var(--font-display);
  color: var(--on-brand-soft);
}

/* ==========================================================================
   Error pages
   ========================================================================== */

.error-page {
  min-height: 62vh;
  display: grid;
  place-items: center;
  text-align: center;
  padding: 64px 0;
}
.error-code {
  font-family: var(--font-display);
  font-style: italic;
  font-size: clamp(4.5rem, 16vw, 8.5rem);
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.02em;
  background: var(--accent-gradient);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  margin-bottom: 0.1em;
}
.error-page h1 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-2); letter-spacing: -0.01em; margin-bottom: 0.4em; }
.error-page p { color: var(--text-soft); max-width: 46ch; margin: 0 auto 1.8em; }

/* The 404 specifically (views/errors/404.ejs): the hero's hidden-word
   canvas, ungated, behind the copy. Isolated so the atmosphere's negative
   z-index still paints above the page background, and the auth pages'
   blurred wash (see .auth-wrap::before, which this shares) sits behind
   the letters for the same reason it sits behind the glass card there:
   flat --bg gave the letters nothing to glow against. The canvas is
   sized off the viewport, not the 1200px shell this page also is, so the
   letters run edge to edge on a wide screen. */
.error-page-lost {
  position: relative;
  isolation: isolate;
  min-height: calc(100vh - 62px);
  /* .error-page's own `padding: 64px 0` zeroes the .shell side padding it
     also carries; the old 404 had nothing wide enough to notice, but the
     search box below is full width and ran edge to edge on a phone. Same
     clamp .shell uses. */
  padding-inline: clamp(18px, 4vw, 32px);
}
.error-atmosphere {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 100vw;
  transform: translateX(-50%);
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}
/* Same blend split as .hero-words: screen over the dark page, multiply
   over the light one, with the same palette-following inks from
   --words-ink and --words-glow. 0.35 rather than the hero's 0.5, this is
   a background to read a page over, not the hero's texture detail. */
.error-words {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  opacity: 0.35;
  mix-blend-mode: screen;
}
:root[data-theme='light'] .error-words { mix-blend-mode: multiply; opacity: 0.3; }
/* The hero's own bottom fade, so the grid closes into the footer rather
   than stopping on a hard edge. */
.error-atmosphere-fade {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, transparent 0%, transparent 46%, color-mix(in srgb, var(--bg) 60%, transparent) 66%, var(--bg) 100%);
}
.error-body { position: relative; }
.error-suggestion { margin: -0.8em auto 1.6em; }
.error-suggestion .card-link { margin-top: 0; font-size: var(--step-0s); }
.error-suggestion em { font-family: var(--font-display); font-style: italic; font-weight: 500; font-size: 1.1em; }
.error-search { width: min(460px, 100%); margin: 0 auto 26px; }
.error-search .search-page-input { margin-bottom: 0; }

/* ==========================================================================
   Reveal on scroll
   ========================================================================== */

/* Scoped to .has-js, which theme.js sets in <head> before first paint.
   Without that scope, a failed or blocked script would leave every
   revealable section permanently at opacity 0, hiding most of the page.
   Content must be visible by default and animated only as an enhancement. */
.has-js .reveal {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
}
.has-js .reveal.is-visible { opacity: 1; transform: none; }

/* A row of cards, shots, or checklist lines all sit at roughly the same
   scroll position, so they cross the IntersectionObserver's threshold in
   the same frame. Without a delay staggered by position, "reveal on
   scroll" reads as one simultaneous pop instead of a cascade. Each group
   is a flat list of direct .reveal children, so nth-child order is enough. */
.has-js .card-grid .reveal:nth-child(2),
.has-js .shot-grid .reveal:nth-child(2),
.has-js .checklist .reveal:nth-child(2) { transition-delay: 0.09s; }
.has-js .card-grid .reveal:nth-child(3),
.has-js .shot-grid .reveal:nth-child(3),
.has-js .checklist .reveal:nth-child(3) { transition-delay: 0.18s; }
.has-js .checklist .reveal:nth-child(4) { transition-delay: 0.27s; }

/* A split row used to reveal as one flat block, text and screenshot arriving
   in the same instant even though the eye reads the text first. The two
   columns each carry .reveal on their own now (see home.ejs), and the second
   one, the screenshot or code sample, picks up the same step used above so
   it lands a beat after the text settles instead of with it. The child
   combinator keeps this from also catching the checklist items nested one
   level deeper inside the first column, which already have their own delay
   from the rule above. */
.has-js .split > .reveal:nth-child(2) { transition-delay: 0.09s; }

/* Serif headings rise out of their own baseline. Every section head used
   to arrive with the same whole-block fade-and-18px-rise as a paragraph;
   the serif is the site's most distinctive typographic asset and it got a
   paragraph's entrance. The heading text is wrapped in <span class="rise">
   in the views (the hero wordmark excepted, deliberately) and the span
   starts a full line below, clipped by the heading itself, then releases
   over 0.8s once the surrounding .reveal is visible. The parent's own fade
   still runs underneath, and the paragraph after it arrives on the
   existing stagger. clip-path rather than overflow: hidden so the
   descenders of the last line survive, hence the -0.2em bottom inset. The
   :has() guard keeps headings without the wrapper unclipped; a browser
   without :has() simply shows the span rising unmasked. */
.has-js .reveal :is(h1, h2, h3):has(> .rise) { clip-path: inset(0 0 -0.2em 0); }
.has-js .reveal :is(h1, h2, h3) > .rise {
  display: block;
  transform: translateY(110%);
  transition: transform 0.8s var(--ease-out) 0.05s;
}
.has-js .reveal.is-visible :is(h1, h2, h3) > .rise { transform: none; }

/* ==========================================================================
   Community rank
   ========================================================================== */

/* Every rank colour arrives from lib/levels.js as --rank-colour on the
   element itself, so the ladder can be recoloured without touching CSS. */

.rank-card {
  display: flex;
  flex-wrap: wrap;
  gap: 22px;
  align-items: stretch;
  padding: 20px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
}

.rank-badge {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-width: 132px;
  padding: 18px 20px;
  border-radius: var(--radius);
  border: 1px solid color-mix(in srgb, var(--rank-colour) 45%, transparent);
  background: color-mix(in srgb, var(--rank-colour) 12%, transparent);
  text-align: center;
}

.rank-level {
  font-size: var(--step-3);
  font-weight: 700;
  line-height: 1;
  color: var(--rank-colour);
}

.rank-name {
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--text-soft);
}

.rank-body { flex: 1 1 320px; display: flex; flex-direction: column; gap: 16px; }

.rank-figures {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
  gap: 14px;
  margin: 0;
}
.rank-figures dt { font-size: var(--step--1); color: var(--text-muted); margin-bottom: 2px; }
.rank-figures dd { margin: 0; font-size: var(--step-1); font-weight: 600; }

.rank-progress-bar {
  height: 8px;
  border-radius: 999px;
  background: var(--surface-raised);
  overflow: hidden;
}
.rank-progress-bar span {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: var(--accent-gradient);
  /* A member sitting at 0% into a level should still see that the bar is a
     bar, so the fill never collapses to nothing. */
  min-width: 4px;
  transition: width 0.6s var(--ease-out);
}
.rank-progress .field-hint { margin-top: 8px; }

.rank-perks { display: flex; flex-wrap: wrap; gap: 8px; }

.link-code {
  font-family: var(--mono);
  font-size: var(--step-1);
  letter-spacing: 0.08em;
  /* A code that is read aloud or retyped has to survive selection, so it is
     a real text node in a box rather than a decorated background. */
  user-select: all;
  padding: 14px 18px;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface);
  display: inline-block;
  margin: 4px 0 12px;
}

.rank-chip {
  display: inline-block;
  padding: 3px 10px;
  border-radius: 999px;
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--rank-colour);
  border: 1px solid color-mix(in srgb, var(--rank-colour) 40%, transparent);
  background: color-mix(in srgb, var(--rank-colour) 10%, transparent);
  white-space: nowrap;
}

/* Period tabs. Real links to real URLs, so a period is shareable, works
   with the back button, and needs no JavaScript to switch. */
.period-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0 0 22px;
}
.period-tab {
  padding: 7px 14px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-soft);
  font-size: var(--step--1);
  font-weight: 600;
  text-decoration: none;
  white-space: nowrap;
  transition: border-color 0.2s var(--ease), color 0.2s var(--ease);
}
.period-tab:hover { border-color: var(--border-strong); color: var(--text); }
/* Scrimmed the same way and for the same reason as .theme-toggle-thumb
   above, see its comment for the actual per-palette numbers. */
.period-tab.is-active {
  color: var(--on-brand);
  border-color: transparent;
  background: linear-gradient(rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.28)), var(--accent-gradient);
}

/* The sign-in identifier toggle borrows .period-tab wholesale (see the
   comment by .identifier-mode-row near .field-eye) rather than inventing a
   second pill component, and only trims its size: this one sits inline
   beside a form label instead of standing alone as a page-level nav, so it
   reads as a small control attached to the field rather than a second
   heading competing with "Sign in" above it. Placed after .period-tab.is-
   active on purpose, equal specificity to the base rule, so file order is
   what makes this win. */
.identifier-mode-tabs { margin: 0; gap: 4px; }
.identifier-mode-tab { padding: 3px 10px; font-size: var(--step--2); }

.leaderboard { width: 100%; border-collapse: collapse; font-size: var(--step-0); }
.leaderboard th,
.leaderboard td { padding: 12px 14px; text-align: left; border-bottom: 1px solid var(--border); }
.leaderboard th { font-size: var(--step--2); color: var(--text-muted); font-weight: 600; }
.leaderboard tbody tr { transition: background 0.15s var(--ease); }
.leaderboard tbody tr:hover { background: var(--surface); }
/* On hover the row's leading edge takes the row's own rank colour, the
   same 2px inset hairline the docs sidebar and the search dropdown use for
   "this one". An inset shadow on the first cell rather than a border-left
   on the row: under border-collapse a row border is shared with its
   neighbours and would not transition cleanly. */
.leaderboard tbody td:first-child {
  box-shadow: inset 2px 0 0 transparent;
  transition: box-shadow 0.2s var(--ease);
}
.leaderboard tbody tr:hover td:first-child { box-shadow: inset 2px 0 0 var(--rank-colour, var(--violet)); }
.leaderboard .col-position { width: 3.5rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
/* Top-3 positions in the italic display serif, the same numeral voice the
   home page's "01" section markers (.eyebrow-index) use, coloured by the
   row's rank. Chosen over a medal shape on purpose: the profile page marks
   a milestone earned with its own tile (see .milestone below), and reusing
   that shape here for a position would make one shape mean two things; a
   filled sphere with a white numeral on it, which is what this page used to
   sit beside, also fails contrast on the ladder's paler greys and pastels,
   while a coloured numeral on the page background passes for every rank
   colour lib/levels.js defines. --rank-colour
   arrives on the <tr> from leaderboard.ejs, so it cascades to every cell;
   the --teal fallback covers an unranked row. */
.leaderboard .col-position.is-top-rank {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 500;
  font-size: 1.45em;
  line-height: 1;
  color: var(--rank-colour, var(--teal));
}
.leaderboard .col-number { text-align: right; font-variant-numeric: tabular-nums; }
.leaderboard-name { font-weight: 600; }
/* Initial avatar beside the name: the account page's .avatar keycap at
   28px, without the glow (a glow per row would be twenty-five glows). An
   inline-flex span inside the cell rather than a flex cell, which would
   drop the <td> out of the table's own layout. */
.leaderboard-reader { display: inline-flex; align-items: center; gap: 10px; }
/* The chapters/time boards' own reader cell is a real <a> to /u/<username>,
   unlike the Discord board's plain <span>. Undoes the generic link
   underline/colour so it still reads as the same row, just clickable. */
a.leaderboard-reader { color: inherit; text-decoration: none; }
a.leaderboard-reader:hover { text-decoration: underline; }
.avatar-sm {
  width: 28px;
  height: 28px;
  border-radius: 9px;
  font-size: var(--step--2);
  font-weight: 700;
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 35%, transparent);
}

/* The footnote under the table: a caption in the table's own voice, not
   the form vocabulary (.field-hint) it was borrowing. */
.table-note {
  margin: 18px 0 0;
  max-width: 66ch;
  font-size: var(--step--1);
  line-height: 1.55;
  color: var(--text-muted);
}

/* Empty board. A .doc-banner tone strip with a glyph, bled to the panel's
   edges, so an empty leaderboard is still a designed page and not a bare
   box with a sentence in it. */
.empty-state { padding-top: 0; overflow: hidden; }
.empty-state-strip {
  display: grid;
  place-items: center;
  height: 96px;
  margin: 0 -26px 22px;
}
.empty-state-strip .doc-icon { width: 34px; height: 34px; }
.empty-state h2 { font-family: var(--font-display); font-weight: 500; font-size: var(--step-2); letter-spacing: -0.01em; margin-bottom: 0.3em; }
.empty-state > p:first-of-type { color: var(--text-soft); font-size: var(--step-0s); margin: 0; max-width: 52ch; }

/* ==========================================================================
   Public profile  /u/:username
   --------------------------------------------------------------------------
   One bordered card with an accent band as its lid. Inside it, hairlines and
   type do all the work: no nested cards, no dial, no boxed stats.

   Two colour mechanisms live here, and they are not the same thing:

   --accent-*     The four palette stops, each mixed a fixed distance toward
                  --text. That single move is both a floor and a ceiling in
                  both themes: Void's near-black stops climb far enough off
                  --bg to be seen, and Mono's literal-white --teal comes back
                  down far enough to stay a colour. The raw stops are usable
                  for the banner and for nothing smaller.

   --rank-colour  The milestone's own integer out of lib/levels.js, set
                  inline per tile. That is data, not styling: the same
                  sanctioned exception this page already made for the old
                  .badge-medal. It is palette-independent, so it takes the
                  --text lift too, for the theme rather than for the palette.

   The four stops themselves are re-declared for this subtree by the
   [data-accent='...'] rows in the palette table near the top of this file,
   which is why there is no per-accent rule anywhere below: one attribute on
   the card retints the banner, the avatar ring, the tick, the progress fill,
   the role tint and the eyebrow at once, and touches nothing else on the
   page.
   ========================================================================== */

.profile-card {
  --profile-surface: var(--surface-solid);
  --profile-pad: clamp(20px, 3vw, 32px);
  --profile-side: 296px;
  --profile-gutter: 32px;
  --profile-avatar: 96px;

  --accent-1: color-mix(in srgb, var(--violet) 78%, var(--text));
  --accent-2: color-mix(in srgb, var(--blue) 82%, var(--text));
  --accent-3: color-mix(in srgb, var(--teal) 80%, var(--text));
  --accent-deep: color-mix(in srgb, var(--indigo) 74%, var(--text));
  /* The one accent that carries small ink: tick disc, eyebrow, badge marks,
     avatar ring. 62% keeps 38% --text in the mix, which holds it against the
     card in every palette. */
  --accent-ink: color-mix(in srgb, var(--teal) 62%, var(--text));

  /* Measured shares, not taste. Each is the highest accent share that keeps
     4.5:1 at its worst point across all ten accents in both themes (the
     sweep is scratchpad/judge/contrast.py); the light-theme block below
     carries the light ceilings, which are lower because the rank hexes and
     the paler palettes have less distance to travel from white. */
  --name-share: 36%;
  --rank-share: 55%;
  --role-share: 42%;

  /* Banner texture. Pure white and black with alpha, the same exception this
     file already makes for scrims and shadows. */
  --banner-rule: rgba(255, 255, 255, 0.07);
  --banner-sheen: rgba(255, 255, 255, 0.08);

  position: relative;
  max-width: 1040px;
  margin: 0 auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--profile-surface);
  /* clip, not hidden, and the pair is deliberate. Both trim the banner to the
     card's rounded corners identically, but overflow: hidden makes this card a
     scroll container, and a scroll container between a sticky element and the
     viewport is what stops that element ever sticking. The identity rail below
     is sticky, so hidden would silently disable it. hidden stays first as the
     fallback for anything that does not know clip; a browser that understands
     clip takes the second declaration and the rail works. */
  overflow: hidden;
  overflow: clip;
  isolation: isolate;
}

/* The global .eyebrow takes raw var(--teal), which on Void is #4A3F66 and all
   but disappears at caps size. Scoped here to the lifted ink instead. */
.profile-card .eyebrow { color: var(--accent-ink); }

/* ---- Banner -------------------------------------------------------------
   Nothing ever sits on this band: no text, no control, no mark. Only the
   avatar breaks its bottom edge, which is what lets it be any palette or any
   photograph with no contrast argument.

   --indigo is a bottom-left vignette rather than the first stop of the ramp:
   it is literal black on Mono and Void, so leading with it opened the band on
   a third of nothing. The ramp starts at violet instead.

   width: 100% is load-bearing: with width auto, aspect-ratio feeds the
   max-height cap back into the width and the banner stops short of the card.
   isolation: isolate is load-bearing too: the image scrim below carries
   z-index 2, and without a stacking context here it escapes the banner and
   paints over the avatar, which is the mechanism behind "the avatar looks
   translucent" on uploaded banners. */
/* 4 / 1, matching BANNER_W / BANNER_H in public/js/site.js, and that pairing
   is load-bearing rather than cosmetic. The crop modal frames and encodes a
   banner at exactly 1440x360; this band used to render 3 / 1, so a stored 4:1
   image met a 3:1 box under object-fit: cover and lost about a quarter of its
   width off both sides. Whatever the reader centred in the crop tool was not
   what the page then showed them. Changing the box rather than the crop keeps
   every banner already uploaded correct, and it is also why this ratio cannot
   be nudged for looks on its own: the two numbers move together or the
   framing contract breaks again. */
.profile-banner {
  position: relative;
  isolation: isolate;
  width: 100%;
  aspect-ratio: 4 / 1;
  max-height: 300px;
  overflow: hidden;
  border-bottom: 1px solid var(--border);
  background:
    radial-gradient(130% 160% at 2% 128%, var(--accent-deep), transparent 60%),
    linear-gradient(100deg, var(--accent-1) 0%, var(--accent-2) 54%, var(--accent-3) 100%);
}
/* Ruled hairlines plus one slow sheen. This is the only articulation Void and
   Mono get, and without it both read as a flat slab. */
.profile-banner::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  background:
    repeating-linear-gradient(100deg, var(--banner-rule) 0 1px, transparent 1px 9px),
    linear-gradient(100deg, transparent 32%, var(--banner-sheen) 56%, transparent 78%);
  -webkit-mask-image: linear-gradient(100deg, transparent, black 38%, black 76%, transparent);
  mask-image: linear-gradient(100deg, transparent, black 38%, black 76%, transparent);
}
/* The uploaded banner. Explicitly under the texture layer above: both are
   absolutely positioned inside the band, and a pseudo-element is always the
   first child, so without these two z-index lines the image would paint over
   the hairlines rather than under them purely by DOM order. */
.profile-banner-image {
  position: absolute;
  inset: 0;
  z-index: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* The hairlines and the sheen exist to stop Void and Mono reading as a flat
   slab. Over a photograph there is no flat slab to fix, so they drop back
   rather than scratching across somebody's picture. */
.profile-banner.has-image::before { opacity: 0.3; }
/* A scrim along the bottom edge only, so the card's border hairline and the
   avatar's cut-out ring always have something to sit against, whatever the
   photograph happens to do down there. Black with alpha, the same exception
   this file already makes for scrims and shadows. */
.profile-banner.has-image::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.34), transparent 62%);
}

/* ---- Two columns --------------------------------------------------------
   The sidebar is the identity, the main column is the content. The main
   column's top padding is half the avatar plus 16, the same drop the name
   has, so one line runs across both columns 64px under the banner. */
.profile-body {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: var(--profile-side) minmax(0, 1fr);
  column-gap: var(--profile-gutter);
}
.profile-side { min-width: 0; padding: 0 0 32px var(--profile-pad); }

/* The identity rail follows the reader down. The main column runs about 900px
   longer than this one on a full profile, so without this the whole lower half
   of the card is an empty 296px strip and the name you are reading about has
   scrolled off the top.

   is-sticky is added by site.js, not by this rule, and that is the whole
   safety mechanism rather than a nicety. This rail can be taller than the
   viewport: measured worst case is 896px, with a 280-character bio, eight
   badges wrapping to three rows, six stats and the owner's own edit link and
   private hint. Pinned unconditionally, everything past the fold would become
   unreachable, because you cannot scroll a stuck element back into view. CSS
   has no way to ask "is this element shorter than the space it has", and the
   usual escape of capping it with overflow-y is closed here: the badge
   tooltips are absolutely positioned and open upward out of the row, so any
   overflow on this column would clip them. So JS measures and this rule only
   applies once it is known to fit.

   align-self sits on the rail itself rather than inside .is-sticky, and it has
   to. A grid item stretches to the row height by default, so this column was
   reporting the height of the whole card (1599px on a full profile) instead of
   its own content, and the fits-in-the-viewport test above could never pass.
   Measuring the box only tells the truth once the box has stopped being
   stretched, so this is not decoration on the sticky rule, it is what makes
   the measurement mean anything. It changes nothing visually on its own: this
   column has no background and no border to shorten.

   top clears the 62px sticky site header with 20px to spare. */
@media (min-width: 900px) {
  .profile-side { align-self: start; }
  .profile-side.is-sticky {
    position: sticky;
    top: 82px;
  }
}
.profile-main { min-width: 0; padding: calc(var(--profile-avatar) / 2 + 16px) var(--profile-pad) 32px 0; }

/* ---- Avatar -------------------------------------------------------------
   Four layers, verified by pixel and not by trust: an opaque disc in the
   card's own colour (so the transparent default SVG reads as a solid
   object), a 4px ring in the card colour that cuts it out of the banner, a
   1px ring in the lifted accent ink, then a soft drop. Shadows rather than
   borders, so the ring costs no layout and the avatar's left edge is the
   sidebar's x. */
.profile-avatar-wrap {
  position: relative;
  z-index: 2;
  width: var(--profile-avatar);
  height: var(--profile-avatar);
  margin-top: calc(var(--profile-avatar) / -2);
  border-radius: 50%;
  background: var(--surface-solid);
  box-shadow:
    0 0 0 4px var(--profile-surface),
    0 0 0 5px var(--accent-ink),
    0 12px 26px -14px rgba(0, 0, 0, 0.7);
}
.profile-avatar {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  background: var(--surface-solid);
}

/* ---- Name ---------------------------------------------------------------
   Solid var(--text) on the h1 is the fallback; the gradient is clipped to
   the inner span only where background-clip: text exists, and the tick
   outside that span keeps its own disc. 36% accent is the measured ceiling:
   the worst point of the ramp (light Mono, at the teal end) sits at 5.41:1,
   and 42% is the first share that fails. */
.profile-name {
  margin: 16px 0 0;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-3);
  line-height: 1.05;
  letter-spacing: -0.018em;
  color: var(--text);
  overflow-wrap: anywhere;
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .profile-name-text {
    background-image: linear-gradient(100deg,
      color-mix(in srgb, var(--violet) var(--name-share), var(--text)),
      color-mix(in srgb, var(--blue) var(--name-share), var(--text)) 55%,
      color-mix(in srgb, var(--teal) var(--name-share), var(--text)));
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    -webkit-box-decoration-break: clone;
    box-decoration-break: clone;
  }
}

/* ---- Verified tick ------------------------------------------------------
   An eight-lobed seal, not the plain disc this used to be. Eight is the
   count X uses, and it is the reason their badge reads as something stamped
   rather than as a sticker: the lobes are shallow enough (theirs measure
   8.2% of the radius; ours are 8.2% too, arrived at independently) that at
   16px they register as a softly serrated edge rather than as a flower.

   The outline is a polar rosette, r = R + A*cos(8t), sampled at its sixteen
   extrema and joined with cubic segments whose control points come from the
   curve's own derivative rather than from eyeballed handles. Worst-case
   deviation from the true curve is 0.04px at the size this actually renders,
   so it is the real shape, not an impression of one. It is our own geometry
   on purpose: the form language is the borrowed part, the path is not.

   Three layers, because no single element can do all three jobs:
     ::before   the seal, a live CSS gradient clipped by that shape. A mask
                rather than an SVG fill, so the gradient goes on answering to
                the theme and to whichever of the ten accents is set.
     svg        the check, a real stroked path so it can draw itself on.
     ::after    the sheen, the same shape masked again, swept across on
                arrival and again on hover.

   The check is stroked in the card's own colour rather than punched out of
   the seal the way X does it. Theirs is a knockout, a second subpath winding
   backwards through the same fill, which is correct on their white timeline
   and turns near-black on a dark card like this one. Stroking it also gives
   the path a length to animate along, which a knockout would not have.

   --accent-ink anchors every colour here for the same reason it did before:
   it is the one value already proven to clear contrast against the card in
   both themes and all ten accents, so the gradient lifts away from it rather
   than being mixed freely. That held for --accent-ink itself, but the seal's
   own gradient mixes further toward --violet and --teal on top of it (see
   below), and under void, where both of those are already close to black,
   that extra mix pulls the seal's body dark enough that the dark
   --seal-ink stroke nearly disappears into it: measured contrast 2.12,
   against 5 to 8+ everywhere else. void gets its own --seal-ink below
   rather than a change to the shared formula, since nothing else measured
   as a problem. */
.profile-tick,
.admin-check {
  /* One shape, shared by the seal, its sheen, and the admin table's copy of
     this badge, which differs only in being sized in px. */
  --seal-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 1.13C13.42 1.13 14.42 3.01 15.53 3.47C16.65 3.93 18.68 3.31 19.69 4.31C20.69 5.32 20.07 7.35 20.53 8.47C20.99 9.58 22.87 10.58 22.87 12C22.87 13.42 20.99 14.42 20.53 15.53C20.07 16.65 20.69 18.68 19.69 19.69C18.68 20.69 16.65 20.07 15.53 20.53C14.42 20.99 13.42 22.87 12 22.87C10.58 22.87 9.58 20.99 8.47 20.53C7.35 20.07 5.32 20.69 4.31 19.69C3.31 18.68 3.93 16.65 3.47 15.53C3.01 14.42 1.13 13.42 1.13 12C1.13 10.58 3.01 9.58 3.47 8.47C3.93 7.35 3.31 5.32 4.31 4.31C5.32 3.31 7.35 3.93 8.47 3.47C9.58 3.01 10.58 1.13 12 1.13Z'/%3E%3C/svg%3E");
  --seal-ink: var(--profile-surface, var(--surface-solid));
  --seal-tone: var(--accent-ink, var(--teal));
  position: relative;
  display: inline-grid;
  place-items: center;
  isolation: isolate;
}
/* void's own --violet/--teal are both already near-black (see the palette
   definitions above), so the dark --seal-ink this component uses everywhere
   else stops being legible against them. --on-brand is the existing "ink
   that sits on a colourful gradient, not on the page" token this codebase
   already uses for the same reason elsewhere (the hero CTA button, the
   footer wordmark), and it is fixed white regardless of theme, which is
   what void's own tokens need here since void does not lighten in light
   theme either. Targeted at .profile-tick/.admin-check themselves, as a
   descendant of the accented element, not just at [data-accent='void']:
   .profile-tick's own rule above sets --seal-ink directly on the same
   element, and a direct declaration always wins over one only inherited
   from an ancestor, regardless of which selector is more specific. */
[data-accent='void'] .profile-tick,
[data-accent='void'] .admin-check {
  --seal-ink: var(--on-brand);
}

.profile-tick::before,
.profile-tick::after,
.admin-check::before,
.admin-check::after {
  content: '';
  position: absolute;
  inset: 0;
  -webkit-mask: var(--seal-shape) center / contain no-repeat;
  mask: var(--seal-shape) center / contain no-repeat;
}

/* The seal itself. The gradient runs across the brand's own order (violet
   into blue into teal) but every stop is mixed back toward --seal-tone, so
   it stays a lit version of the one colour already known to hold against
   the card instead of drifting somewhere untested. */
.profile-tick::before,
.admin-check::before {
  z-index: 0;
  background-image: linear-gradient(145deg,
    color-mix(in srgb, var(--violet) 46%, var(--seal-tone)),
    var(--seal-tone) 52%,
    color-mix(in srgb, var(--teal) 38%, var(--seal-tone)));
}

/* The check sits above the seal and below the sheen. */
.profile-tick svg,
.admin-check svg {
  position: relative;
  z-index: 1;
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.profile-tick path,
.admin-check path {
  fill: none;
  stroke: var(--seal-ink);
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The sheen. Masked to the seal so the highlight cannot spill past the
   lobes, and moved by background-position rather than by a transform, since
   transforming the layer would drag its mask along with it and the mask is
   the part that has to hold still. Invisible until something plays it. */
.profile-tick::after,
.admin-check::after {
  z-index: 2;
  opacity: 0;
  background-image: linear-gradient(102deg,
    transparent 0%,
    color-mix(in srgb, #fff 82%, transparent) 50%,
    transparent 100%);
  background-repeat: no-repeat;
  background-size: 44% 220%;
  background-position: -70% 50%;
}

.profile-tick {
  width: 0.76em;
  height: 0.76em;
  margin-left: 0.16em;
  translate: 0 -0.06em;
  vertical-align: baseline;
}

/* ---- Verified tick: the arrival ----------------------------------------
   Three beats, staggered so they read as one gesture rather than three
   animations that happen to overlap: the seal presses in, the check draws
   itself along the stroke it just landed on, and a highlight passes over
   the finished badge. 520 + 240 + a sheen that starts as the check finishes
   puts the whole thing under a second and a half, and nothing loops.

   Curve is cubic-bezier(0.23, 1, 0.32, 1) throughout, the strong ease-out
   Emil Kowalski's animation standards use as the default for anything
   entering. Nothing here uses ease-in, and nothing animates a property that
   forces layout: the seal moves on transform and opacity, the check on
   stroke-dashoffset (paint only, and only inside its own 24px box), the
   sheen on background-position.

   pathLength="1" on the check in the markup is what makes the draw portable:
   the dash length is normalised to 1 regardless of the path's real length,
   so the geometry can be retuned later without recomputing a magic number
   here. */
@keyframes tick-seal-in {
  from { opacity: 0; transform: scale(0.62) rotate(-38deg); }
  to   { opacity: 1; transform: scale(1) rotate(0deg); }
}
@keyframes tick-check-draw {
  from { stroke-dashoffset: 1; }
  to   { stroke-dashoffset: 0; }
}
@keyframes tick-sheen {
  0%   { opacity: 0; background-position: -70% 50%; }
  22%  { opacity: 1; }
  78%  { opacity: 1; }
  100% { opacity: 0; background-position: 170% 50%; }
}

@media (prefers-reduced-motion: no-preference) {
  .profile-tick::before {
    animation: tick-seal-in 520ms cubic-bezier(0.23, 1, 0.32, 1) both;
  }
  .profile-tick path {
    stroke-dasharray: 1;
    animation: tick-check-draw 360ms cubic-bezier(0.23, 1, 0.32, 1) 240ms both;
  }
  .profile-tick::after {
    animation: tick-sheen 900ms cubic-bezier(0.23, 1, 0.32, 1) 620ms both;
  }

  /* Hover replays the sheen and lifts the seal a little. Gated to real
     pointers so a tap on a phone does not fire a hover state that then has
     nothing to leave. */
  @media (hover: hover) and (pointer: fine) {
    .profile-name:hover .profile-tick::before {
      transform: scale(1.09);
      transition: transform 220ms cubic-bezier(0.23, 1, 0.32, 1);
    }
    .profile-name:hover .profile-tick::after {
      animation: tick-sheen 760ms cubic-bezier(0.23, 1, 0.32, 1) both;
    }
  }
}

/* Reduced motion keeps the badge and drops the performance: no press, no
   draw, no sweep. The check has to be told to show itself, since the dash
   properties above only exist inside the no-preference block but the markup
   still carries pathLength. web.dev's guidance is to declare motion only
   under no-preference rather than to switch it off afterwards, which is why
   this direction is the safe one to author. */
@media (prefers-reduced-motion: reduce) {
  .profile-tick::before { opacity: 1; }
  .profile-tick path { stroke-dasharray: none; }
}

.profile-handle {
  margin: 4px 0 0;
  font-size: var(--step-0s);
  line-height: 1.4;
  color: var(--text-soft);
}

.profile-edit-link { display: inline-flex; margin-top: 12px; }

/* ---- Role marks ---------------------------------------------------------
   One recipe, one custom property, set inline per role rather than through a
   per-key class, see routes/pages.js, views/admin/index.ejs and views/
   admin/user.ejs, which each write --role-tone (and --role-tone-2, for a
   gradient role) straight onto the element from role_definitions.tone_key/
   tone_key_2. That indirection is what makes a role_definitions row created
   from the admin panel render correctly with zero CSS to write: a class-
   based rule, the way this used to work, cannot exist for a key nobody has
   written a stylesheet rule for yet. Every role still carries a distinct
   silhouette as well as a tint (views/partials/role-glyph.ejs), because on
   Mono and Void several tints collapse toward the same grey by design, so
   the colour is the hint and the glyph is the message. The label share is
   measured (see --role-share): 42% holds in dark; light needs 36%, where
   translator on Mono is the floor. */
.role-row {
  display: flex;
  flex-wrap: wrap;
  gap: 5px 8px;
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
}
/* Stagger index, same device as .badge. Seven rules because the seed carries
   seven roles and nothing assigns more than that to one account today; an
   eighth simply lands on --i: 0 and arrives with the first, which is a
   duller entrance rather than a broken one. */
.role-row > * { --i: 0; }
.role-row > :nth-child(2) { --i: 1; }
.role-row > :nth-child(3) { --i: 2; }
.role-row > :nth-child(4) { --i: 3; }
.role-row > :nth-child(5) { --i: 4; }
.role-row > :nth-child(6) { --i: 5; }
.role-row > :nth-child(7) { --i: 6; }
.role-mark {
  --role-tone: var(--violet);
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 11px 4px 9px;
  border-radius: 999px;
  font-size: var(--step--2);
  font-weight: 700;
  line-height: 1.4;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  white-space: nowrap;
  overflow: hidden;
  color: color-mix(in srgb, var(--role-tone) var(--role-share), var(--text));
  border: 1px solid color-mix(in srgb, var(--role-tone) 44%, var(--border));
  /* Two stops where there was one flat 10% tint, lit from the top left to
     match the badge marks and the verified seal. The pair averages the 10%
     this has always been rather than lifting off it, so the measured
     --role-share label contrast above is unchanged: 14 at the top edge,
     which is padding, falling through 10 behind the label itself to 6 at
     the bottom. Verified by measurement, not by eye, see the audit note
     under .role-row above. */
  background-image: linear-gradient(160deg,
    color-mix(in srgb, var(--role-tone) 14%, transparent),
    color-mix(in srgb, var(--role-tone) 6%, transparent));
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 11%, transparent),
    0 1px 2px rgba(0, 0, 0, 0.18);
  transition:
    transform 200ms cubic-bezier(0.23, 1, 0.32, 1),
    border-color 200ms var(--ease),
    box-shadow 200ms var(--ease),
    background-image 200ms var(--ease);
}
.role-mark svg { flex: none; width: 13px; height: 13px; fill: currentColor; }

/* Pride of place: the one filled, two-tone mark rather than every other
   role's bordered single tint. Opt in by giving a role_definitions row a
   second tone (tone_key_2), which the route hands through as --role-tone-2
   and this attribute. Creator is seeded with one, so its exact look (the
   gradient mixed toward --bg at both stops, label held at var(--text)
   rather than a fixed white so it holds in light theme too) is unchanged;
   any other role can now opt into the identical treatment from the admin
   panel with no CSS of its own. */
.role-mark[data-gradient] {
  --role-tone-2: var(--role-tone);
  color: var(--text);
  border-color: color-mix(in srgb, var(--role-tone-2) 50%, transparent);
  background-image: linear-gradient(120deg,
    color-mix(in srgb, var(--role-tone) 40%, var(--bg)),
    color-mix(in srgb, var(--role-tone-2) 32%, var(--bg)));
  /* The only role that casts colour rather than just wearing it. Small, so
     it reads as the mark being lit rather than as a halo around a pill. */
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 20%, transparent),
    0 2px 8px color-mix(in srgb, var(--role-tone) 26%, transparent);
}

/* The sheen, on the gradient role only. Same device as the verified seal and
   deliberately the same restraint: it exists, it is not running. Nothing
   plays it until a pointer is actually on the mark, which keeps it clear of
   WCAG 2.2.2. There is no auto-playing loop here to pause. */
.role-mark[data-gradient]::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  background-image: linear-gradient(104deg,
    transparent 0%,
    color-mix(in srgb, #fff 30%, transparent) 50%,
    transparent 100%);
  background-repeat: no-repeat;
  background-size: 38% 240%;
  background-position: -60% 50%;
}
@keyframes role-sheen {
  0%   { opacity: 0; background-position: -60% 50%; }
  25%  { opacity: 1; }
  75%  { opacity: 1; }
  100% { opacity: 0; background-position: 165% 50%; }
}

/* Hover is split the same way the badge marks are: the colour half applies
   to everyone, the movement half is gated to real pointers and to
   no-preference, so a tap on a phone cannot strand a mark in a lifted state
   and reduced motion still gets the feedback without the travel. */
.role-mark:hover {
  border-color: color-mix(in srgb, var(--role-tone) 72%, var(--border));
  background-image: linear-gradient(160deg,
    color-mix(in srgb, var(--role-tone) 22%, transparent),
    color-mix(in srgb, var(--role-tone) 10%, transparent));
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent),
    0 4px 10px color-mix(in srgb, var(--role-tone) 20%, transparent);
}
.role-mark[data-gradient]:hover {
  border-color: color-mix(in srgb, var(--role-tone-2) 72%, transparent);
  background-image: linear-gradient(120deg,
    color-mix(in srgb, var(--role-tone) 46%, var(--bg)),
    color-mix(in srgb, var(--role-tone-2) 38%, var(--bg)));
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent),
    0 5px 14px color-mix(in srgb, var(--role-tone) 36%, transparent);
}

/* The row deals itself in like the badge row, 60ms apart, rarest first --
   roles are ordered by role_definitions.position, so Creator leads.
   The entrance moves on translate/scale and the hover moves on transform:
   they are separate properties that compose, which is what lets an
   animation with fill-mode: both sit on the same element as a transition
   without killing it. (The badge row solves the same problem by splitting
   across two elements; a role mark is a single li with nowhere to split.) */
@keyframes role-in {
  from { opacity: 0; translate: 0 6px; scale: 0.95; }
  to   { opacity: 1; translate: 0 0; scale: 1; }
}
@media (prefers-reduced-motion: no-preference) {
  .role-mark {
    animation: role-in 420ms cubic-bezier(0.23, 1, 0.32, 1) calc(var(--i, 0) * 60ms) both;
  }
  @media (hover: hover) and (pointer: fine) {
    .role-mark:hover { transform: translateY(-2px); }
    .role-mark[data-gradient]:hover::after {
      animation: role-sheen 760ms cubic-bezier(0.23, 1, 0.32, 1) both;
    }
  }
}

/* ---- Bio and joined ----------------------------------------------------- */
.profile-bio {
  margin: 16px 0 0;
  font-size: var(--step-0);
  line-height: 1.5;
  color: var(--text-soft);
}
.profile-bio-empty {
  font-family: var(--font-display);
  font-style: italic;
  color: var(--text-muted);
}
.profile-joined {
  margin: 8px 0 0;
  font-family: var(--font-display);
  font-style: italic;
  font-size: var(--step--1);
  line-height: 1.5;
  color: var(--text-muted);
}

/* ---- Sidebar groups: hairlines, not boxes ------------------------------- */
.side-group {
  margin-top: 24px;
  padding-top: 24px;
  border-top: 1px solid var(--border);
}
.side-label {
  display: block;
  margin: 0;
  font-size: var(--step--2);
  font-weight: 700;
  line-height: 1.4;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
}

/* ---- Earned badges ------------------------------------------------------
   Discord's shape: small marks in one row, rarest first, a tooltip each.
   The trigger is a real button so keyboard focus and tap both work; the
   tooltip is CSS on hover and focus-visible, and .is-open for tap (site.js).
   --i is the mark's index in the row, which the tooltip caret below reads to
   slide back under its own mark. */
.badge-row {
  /* The mark size and the row gap are declared once, here, because three
     other things are measured off them: the flex gap, the mark itself, and
     the tooltip caret's offset back under its own mark. That last one used
     to be a hardcoded 30px carrying a comment explaining it was 22 + 8,
     which is exactly the kind of sum that silently stops being true the
     first time anyone resizes a badge. It is now derived. */
  --badge-size: 30px;
  --badge-gap: 9px;
  position: relative;
  display: flex;
  flex-wrap: wrap;
  gap: var(--badge-gap);
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
}
.badge { --i: 0; }
.badge:nth-child(2) { --i: 1; }
.badge:nth-child(3) { --i: 2; }
.badge:nth-child(4) { --i: 3; }
.badge:nth-child(5) { --i: 4; }
.badge:nth-child(6) { --i: 5; }
.badge:nth-child(7) { --i: 6; }
.badge:nth-child(8) { --i: 7; }
/* 30px rather than the 22 these were: at 22 with a 1px hairline border and a
   14px glyph they read as UI affordances, not as things anyone earned. The
   extra 8px is what buys the fill a visible gradient, the rim a highlight,
   and the glyph room to be legible instead of merely present. */
.badge-mark {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--badge-size);
  height: var(--badge-size);
  padding: 0;
  margin: 0;
  border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--accent-ink) 40%, var(--border));
  /* This is a <button>, and a browser's own default button face (a solid
     mid-grey, not this card's near-black surface) is otherwise left free to
     paint underneath the tint below, since setting background-image alone
     never clears the separate background-color longhand. See
     .claude/audit-loop-log.md, pass 4, for how much that was actually
     muddying every mark: measured contrast on .tag-mark's aura and flat
     finishes roughly tripled once this line went in. */
  background-color: transparent;
  /* Two stops rather than one flat tint, lit from the top left, so the disc
     has a direction to it. The inset highlight on the box-shadow below is
     the other half of that: a bright hairline along the top rim only, which
     is what makes a flat circle read as a raised one. */
  background-image: linear-gradient(160deg,
    color-mix(in srgb, var(--accent-ink) 17%, transparent),
    color-mix(in srgb, var(--accent-ink) 5%, transparent));
  color: var(--accent-ink);
  font: inherit;
  cursor: default;
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent),
    0 1px 2px rgba(0, 0, 0, 0.22);
  transition:
    transform 200ms cubic-bezier(0.23, 1, 0.32, 1),
    border-color 200ms var(--ease),
    box-shadow 200ms var(--ease),
    background-image 200ms var(--ease);
}
.badge-mark svg { display: block; width: 16px; height: 16px; fill: currentColor; }
.badge-mark .stroke { fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.badge-mark .stroke-faint { opacity: 0.3; }
.badge:hover .badge-mark,
.badge-mark:focus-visible,
.badge.is-open .badge-mark {
  border-color: var(--accent-ink);
  background-image: linear-gradient(160deg,
    color-mix(in srgb, var(--accent-ink) 30%, transparent),
    color-mix(in srgb, var(--accent-ink) 12%, transparent));
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent),
    0 4px 10px color-mix(in srgb, var(--accent-ink) 22%, transparent);
}
/* The lift is motion, so it is gated twice: to real pointers, so a tap on a
   phone cannot strand a mark in a hover state it has no way to leave, and to
   no-preference. The colour half of :hover above stays either way, which is
   the distinction reduced-motion actually asks for. Fewer moving things,
   not fewer legible states. */
@media (hover: hover) and (pointer: fine) {
  @media (prefers-reduced-motion: no-preference) {
    .badge:hover .badge-mark { transform: translateY(-2px) scale(1.06); }
  }
}
.badge-mark:focus-visible { outline: 2px solid var(--accent-ink); outline-offset: 2px; }

/* Podium marks are the rarest thing on the page and the only filled ones.
   They take the same gradient treatment as the verified seal (brand order,
   every stop mixed back toward --accent-ink so contrast stays where it was
   measured) and the only real glow on the card, then step down toward the
   card surface for second and third. */
.badge-podium .badge-mark {
  background-image: linear-gradient(150deg,
    color-mix(in srgb, var(--violet) 40%, var(--accent-ink)),
    var(--accent-ink) 54%,
    color-mix(in srgb, var(--teal) 32%, var(--accent-ink)));
  background-color: var(--accent-ink);
  color: var(--profile-surface, var(--surface-solid));
  border-color: transparent;
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 30%, transparent),
    0 2px 8px color-mix(in srgb, var(--accent-ink) 32%, transparent);
}
.badge-podium-2 .badge-mark { background-color: color-mix(in srgb, var(--accent-ink) 72%, var(--profile-surface, var(--surface-solid))); }
.badge-podium-3 .badge-mark { background-color: color-mix(in srgb, var(--accent-ink) 50%, var(--profile-surface, var(--surface-solid))); }
.badge-podium-2 .badge-mark,
.badge-podium-3 .badge-mark { background-image: none; }
.badge-podium:hover .badge-mark,
.badge-podium.is-open .badge-mark {
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 30%, transparent),
    0 5px 14px color-mix(in srgb, var(--accent-ink) 42%, transparent);
}

/* The row deals itself in, rarest first, 55ms apart. Inside the 30-80ms
   band that reads as one gesture rather than as a queue. --i already existed
   for the tooltip caret; this is its second reader, inherited down from .badge.

   Two things here are deliberate and neither is stylistic.

   It animates .badge-mark, NOT the .badge li. Animating the li put a transform
   on it, and a transformed element becomes the containing block for its own
   absolutely-positioned descendants, which includes .badge-tip. The tooltip
   is anchored left: 0 to the ROW so it cannot overflow a narrow sidebar, and
   its caret walks back to its own mark by --i pitches. Anchored to the li
   instead, that walk started from the mark rather than from the row, so every
   tooltip after the first pointed --i pitches too far right and appeared to
   belong to a different badge. The mark is a sibling of the tooltip, not an
   ancestor, so a containing block there is harmless.

   It moves on translate and scale, not transform, because the hover lift on
   .badge-mark is a transform transition and an animation with fill-mode: both
   would otherwise win the cascade and leave that hover dead. translate/scale
   are independent properties that compose with transform rather than
   replacing it, so the entrance and the hover can share one element. */
@keyframes badge-in {
  from { opacity: 0; translate: 0 7px; scale: 0.86; }
  to   { opacity: 1; translate: 0 0; scale: 1; }
}
@media (prefers-reduced-motion: no-preference) {
  .badge-mark {
    animation: badge-in 420ms cubic-bezier(0.23, 1, 0.32, 1) calc(var(--i, 0) * 55ms) both;
  }
}

/* Every tooltip is anchored to the row's left edge rather than its own
   mark, so none can run past the sidebar (or the 390 viewport); the caret
   slides back under its own mark by the row pitch, which it now computes
   from --badge-size and --badge-gap rather than restating them. */
.badge-tip {
  position: absolute;
  left: 0;
  bottom: calc(100% + 8px);
  z-index: 5;
  width: max-content;
  max-width: 224px;
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-strong);
  background: var(--surface-raised);
  box-shadow: var(--shadow);
  font-size: var(--step--1);
  line-height: 1.4;
  color: var(--text-soft);
  text-align: left;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  translate: 0 4px;
  transition: opacity 0.15s var(--ease), translate 0.15s var(--ease-out), visibility 0s linear 0.15s;
}
.badge-tip b { display: block; color: var(--text); font-weight: 600; }
.badge-tip::after {
  content: '';
  position: absolute;
  top: 100%;
  /* Clamped, because the raw walk can leave the tooltip it belongs to. The
     tip is pinned to the row's left edge and capped at 224px, while the caret
     steps one pitch per mark: by the seventh mark the step is 6 * 39 + 11 =
     245px, past the right edge of its own 224px box, so the caret drew itself
     in mid-air beside the tooltip. The upper bound keeps it inside the box no
     matter how many marks precede it; past that point it stops tracking the
     mark exactly and sits at the tip's right edge, which is the honest
     degradation, and it is still nearer its own mark than to any other.
     The lower bound covers the same escape at the other end.

     This also absorbs the row wrapping. --i is a DOM index from :nth-child
     and knows nothing about line breaks, so the eighth mark in the 264px
     sidebar (7 fit exactly at 7 * 30 + 6 * 9 = 264) starts a second row while
     its caret carries on walking right. Clamping cannot make that point at
     the right mark, but it keeps it on the tooltip instead of loose on the
     card. Fixing it properly needs the row to know its own wrap point, which
     CSS cannot express. */
  left: clamp(6px,
    var(--i) * (var(--badge-size) + var(--badge-gap)) + (var(--badge-size) - 8px) / 2,
    100% - 14px);
  width: 8px;
  height: 8px;
  translate: 0 -4px;
  rotate: 45deg;
  background: var(--surface-raised);
  border-right: 1px solid var(--border-strong);
  border-bottom: 1px solid var(--border-strong);
}
.badge:hover .badge-tip,
.badge-mark:focus-visible + .badge-tip,
.badge.is-open .badge-tip {
  opacity: 1;
  visibility: visible;
  translate: 0 0;
  transition-delay: 0s;
}
.badge-empty {
  margin: 12px 0 0;
  font-family: var(--font-display);
  font-style: italic;
  font-size: var(--step--1);
  color: var(--text-muted);
}

/* ---- Tag marks ----------------------------------------------------------
   Assigned marks, drawn in the same vocabulary as the earned ones: same size,
   same rim, same tooltip, so a row of them reads as one row rather than two
   systems sharing a sidebar. The difference is where they come from. An
   achievement is computed from the leaderboard, the message count or the join
   date and cannot be given; a tag is granted by hand from the admin panel.

   --tag-tone is written inline per mark from tag_definitions.tone_hex, the
   same indirection .role-mark uses, so a tag created in the admin panel gets
   its colour with no rule written here for its key. It is mixed toward --text
   at the same 62% --accent-ink uses, for the same reason: the raw hex is
   chosen for a swatch, not for contrast against this card in ten palettes. */
.tag-mark {
  --tag-ink: color-mix(in srgb, var(--tag-tone, var(--teal)) 62%, var(--text));
  color: var(--tag-ink);
  border-color: color-mix(in srgb, var(--tag-tone, var(--teal)) 40%, var(--border));
  background-image: linear-gradient(160deg,
    color-mix(in srgb, var(--tag-tone, var(--teal)) 17%, transparent),
    color-mix(in srgb, var(--tag-tone, var(--teal)) 5%, transparent));
}
.badge:hover .tag-mark,
.tag-mark:focus-visible,
.badge.is-open .tag-mark {
  border-color: var(--tag-ink);
  background-image: linear-gradient(160deg,
    color-mix(in srgb, var(--tag-tone, var(--teal)) 30%, transparent),
    color-mix(in srgb, var(--tag-tone, var(--teal)) 12%, transparent));
}

/* The two finishes a tag can wear, keyed on tag_definitions.treatment rather
   than on the tag's key. That is the whole point of the column: Respected and
   GOAT wear the halo today, and anything created in the admin panel tomorrow
   can wear it too without a line being added here. */

/* aura: a still gold halo. Gold is hardcoded, which this file otherwise never
   does for a palette colour, and it is the same deliberate exception
   .tone-royal already makes: a halo that rotates with the accent stops
   reading as gold, and gold is the entire idea. The mark keeps its own tone
   ink so the glyph inside still belongs to its palette. */
.tag-mark[data-treatment='aura'] {
  --aura: #e8b84b;
  border-color: color-mix(in srgb, var(--aura) 62%, transparent);
  background-image: linear-gradient(160deg,
    color-mix(in srgb, var(--aura) 26%, transparent),
    color-mix(in srgb, var(--aura) 8%, transparent));
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 26%, transparent),
    0 0 0 1px color-mix(in srgb, var(--aura) 30%, transparent),
    0 0 12px color-mix(in srgb, var(--aura) 42%, transparent);
}
.tag-mark[data-treatment='aura'] { color: color-mix(in srgb, var(--aura) 70%, var(--text)); }
/* The hover rule for a plain tag mark is .badge:hover .tag-mark, which at
   0-3-0 outranks the 0-2-0 treatment rules above and was repainting a gold
   mark in its own tone the moment a pointer touched it. Restating
   background-image at 0-4-0 is what keeps a finish a finish in every state;
   the same applies to the rainbow below, where losing it was not subtle. */
.badge:hover .tag-mark[data-treatment='aura'],
.badge.is-open .tag-mark[data-treatment='aura'],
.tag-mark[data-treatment='aura']:focus-visible {
  border-color: color-mix(in srgb, var(--aura) 80%, transparent);
  background-image: linear-gradient(160deg,
    color-mix(in srgb, var(--aura) 38%, transparent),
    color-mix(in srgb, var(--aura) 14%, transparent));
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 32%, transparent),
    0 0 0 1px color-mix(in srgb, var(--aura) 45%, transparent),
    0 0 20px color-mix(in srgb, var(--aura) 60%, transparent);
}

/* rainbow: the one mark on the card allowed to be loud, and the only place a
   spectrum appears anywhere on this site. The glyph was originally knocked
   out of it in the card's own colour, on the theory that a stroke this thin
   would read against any hue behind it. Measured, it does not: a single ink
   colour cannot clear 3:1 against all six stops at once, since gold needs a
   dark ink and indigo needs a light one and no one colour is both. Fixed by
   giving the glyph a small solid disc of the card's own colour to sit on
   (below), so it only ever has to contrast against that one fixed colour,
   the same --text against --profile-surface pairing the rest of this card
   already relies on, not against the spectrum directly. The ring of colour
   around that disc is untouched, still the whole reason this treatment
   exists.

   The conic gradient turns only on hover, and only for a pointer that can
   hover. A mark this size spinning permanently in a sidebar is a distraction
   the reader cannot dismiss, which is what WCAG 2.2.2 is about; at rest it is
   simply a still spectrum. @property is what makes --spin animatable at all:
   an unregistered custom property is a token to the animation engine and
   jumps rather than interpolating. */
@property --spin {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}
.tag-mark[data-treatment='rainbow'] {
  border-color: transparent;
  background-color: transparent;
  background-image: conic-gradient(from var(--spin),
    #e8503a, #e8b84b, #6fd08a, #17aacb, #4b3aed, #a24bd8, #e8503a);
  color: var(--text);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 30%, transparent),
    0 2px 8px rgba(0, 0, 0, 0.28);
}
/* Sized to the glyph's own footprint (roughly), not the badge's, so it
   reads as a small chip behind the icon rather than as a second, smaller
   badge stacked on the first. .badge-mark is already position: relative,
   so this positions against the mark itself. The svg is a real DOM child
   and paints after this generated one without needing its own z-index. */
.tag-mark[data-treatment='rainbow']::before {
  content: '';
  position: absolute;
  inset: 22%;
  border-radius: 50%;
  background: var(--profile-surface, var(--surface-solid));
}
/* Restated at 0-4-0 so the generic .badge:hover .tag-mark tint cannot flatten
   the spectrum back to a single hue, which is what it did before this rule
   existed: the mark went brown the instant a pointer arrived. */
.badge:hover .tag-mark[data-treatment='rainbow'],
.badge.is-open .tag-mark[data-treatment='rainbow'],
.tag-mark[data-treatment='rainbow']:focus-visible {
  border-color: transparent;
  background-image: conic-gradient(from var(--spin),
    #e8503a, #e8b84b, #6fd08a, #17aacb, #4b3aed, #a24bd8, #e8503a);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 36%, transparent),
    0 4px 14px rgba(0, 0, 0, 0.32);
}
@keyframes tag-spin { to { --spin: 360deg; } }
@media (prefers-reduced-motion: no-preference) {
  @media (hover: hover) and (pointer: fine) {
    .badge:hover .tag-mark[data-treatment='rainbow'] {
      animation: tag-spin 2.4s linear infinite;
    }
  }
}

/* ---- Stats: a vertical label and value list -----------------------------
   The group's own padding is the 24 under the hairline, so the list carries
   no margin of its own. */
/* Two up, not one. routes/pages.js builds as many as six of these (level,
   leaderboard place, XP, messages, milestones passed, member-for), and a
   single column turned that into roughly 460px of stacked sidebar against a
   main column that had already run out of ladder, which is where most of the
   card's dead space at the bottom right came from. auto-fit rather than a
   fixed 2 so the same rule collapses to one column when the sidebar drops
   under the track below, and widens if a seventh stat is ever added. */
.stat-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
  gap: 16px 14px;
  margin: 0;
}
.stat-list .stat { min-width: 0; }
.stat-label {
  display: block;
  margin: 0;
  font-size: var(--step--2);
  font-weight: 700;
  line-height: 1.4;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
}
.stat-figure {
  display: block;
  margin: 4px 0 0;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-1);
  line-height: 1.1;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums lining-nums;
  color: var(--text);
}
/* "yr", "mo", "No.", "of 29" stay in the sans at meta size, so the figure
   reads as the figure and the unit reads as a unit. */
.stat-figure .unit {
  font-family: var(--font);
  font-size: var(--step--1);
  font-weight: 500;
  letter-spacing: 0;
  color: var(--text-muted);
}

/* ---- Main column: ladder head ------------------------------------------- */
.ladder-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 12px;
  margin: 0 0 16px;
}
.ladder-title, .main-title {
  margin: 4px 0 0;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-1);
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--text);
}
.ladder-count {
  margin: 0;
  font-family: var(--font-display);
  font-style: italic;
  font-size: var(--step--1);
  color: var(--text-muted);
  white-space: nowrap;
}
.main-group {
  margin-top: 24px;
  padding-top: 24px;
  border-top: 1px solid var(--border);
}

/* ---- Progress -----------------------------------------------------------
   A running head over a 3px rule. The rank being worked toward is named in
   words here and ghosted as the last tile below. */
.profile-progress { margin: 0; }
.progress-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin: 0 0 8px;
  font-size: var(--step--2);
  color: var(--text-muted);
}
.progress-next { font-weight: 700; text-transform: uppercase; letter-spacing: var(--track-caps); }
.progress-next b { color: var(--text-soft); font-weight: 700; }
.progress-count { font-variant-numeric: tabular-nums lining-nums; white-space: nowrap; }
.progress-count b { color: var(--text-soft); font-weight: 700; }
.profile-track {
  height: 3px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--text) 10%, transparent);
  overflow: hidden;
}
.profile-fill {
  height: 100%;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--accent-1), var(--accent-2) 55%, var(--accent-3));
}

/* ---- Milestone tiles ----------------------------------------------------
   Tiles, not spheres. The level numeral in the display serif is the content,
   the milestone's own colour is a 2px rule and a wash, and the name wraps to
   two lines rather than ellipsing. The numeral share (--rank-share) is
   measured against the held tile's 26% wash, not the plain tile's 12%: that
   is where the dark-theme floor actually is (#4b3aed at 4.70 on 55%). */
/* ---- The rung line ------------------------------------------------------
   The ladder as one object: a track, a node per rung, the rung held marked
   and the rung being chased hollow at the end. It replaced a grid of equal
   rounded tiles which read as a row of cards rather than as a climb, and
   which always orphaned its last tile onto a second row of its own.

   Nodes are spaced evenly by index rather than by level, which the template
   comment above explains at length and which is load-bearing: the ladder runs
   5, 10, 15, 20, 30 ... 200 ... 1000, so true-to-level positions would pile
   most of the rungs into the left sixth of the track for anyone high up it.
   Flex with equal basis does the spacing, so there is no calc dividing by a
   node count and no division by zero when a new account has one rung. */
.rungline {
  display: flex;
  align-items: flex-start;
  margin: 18px 0 0;
  padding: 0;
  list-style: none;
}
.rung {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  padding-top: 19px;
  text-align: center;
}
/* Each rung draws the segment of track arriving at it, so the line is built
   from the same elements as the nodes and cannot drift out of step with them.
   The first rung has nothing to its left and draws none. */
.rung::before {
  content: '';
  position: absolute;
  top: 4px;
  left: -50%;
  width: 100%;
  height: 3px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--rank-colour) 60%, transparent);
}
.rung:first-child::before { content: none; }
.rung-dot {
  position: absolute;
  top: 0;
  left: 50%;
  translate: -50% 0;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--rank-colour);
}
.rung-label { display: block; }
.rung-label b {
  display: block;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-0);
  line-height: 1.1;
  font-variant-numeric: tabular-nums lining-nums;
  color: color-mix(in srgb, var(--rank-colour) var(--rank-share), var(--text));
  opacity: 0.6;
}
.rung-name {
  display: none;
  margin-top: 3px;
  font-size: var(--step--2);
  line-height: 1.25;
  color: var(--text-muted);
}

/* The rung held. The only lit node on the row, and the only glow: this is the
   single thing a visitor is here to read off this row. */
.rung-current .rung-dot {
  width: 17px;
  height: 17px;
  top: -3px;
  background: var(--profile-surface, var(--surface-solid));
  box-shadow:
    0 0 0 3px var(--rank-colour),
    0 0 14px color-mix(in srgb, var(--rank-colour) 55%, transparent);
}
.rung-current .rung-label b { opacity: 1; }
.rung-current .rung-name { display: block; color: var(--text-soft); }

/* The rung being chased: hollow, and the track arriving at it is dashed,
   so the unwalked segment reads as unwalked without needing a second colour. */
.rung-next .rung-dot {
  background: transparent;
  box-shadow: inset 0 0 0 2px var(--border-strong);
}
.rung-next::before {
  background: repeating-linear-gradient(90deg,
    var(--border-strong) 0 4px, transparent 4px 8px);
}
.rung-next .rung-label b { color: var(--text-muted); opacity: 1; }
.rung-next .rung-name { display: block; }

/* Past nine rungs a cell is too narrow to hold a label. At 29 rungs it is
   about 22px, which "1000" alone overflows, so anything shown per-node
   collides with its neighbour rather than merely crowding it.

   So dense mode shows exactly ONE label on the row, the rung currently held,
   and nothing else. No information is lost by that: the heading above this
   row is already the current rung's name, and the progress line directly
   above already reads "Next: <name> at <level>", so both names and the next
   level are on the page twice before this row draws them a third time. The
   nodes keep saying how far along the ladder the account is, which is the one
   thing only this row can say. */
.rungline.is-dense .rung-label { display: none; }
.rungline.is-dense .rung-current .rung-label { display: block; }
.rungline.is-dense .rung-current .rung-name { display: none; }
.rungline.is-dense .rung { padding-top: 17px; }
/* The held rung can be the last cell (a capped account) or near it, where a
   centred label runs past the column. Anchoring it to the right only matters
   in dense mode, since that is the only mode where a label sits this close to
   the end without a neighbour to balance it. */
.rungline.is-dense .rung:last-child .rung-label,
.rungline.is-dense .rung:nth-last-child(2) .rung-label { text-align: right; }

/* Every dot sits at the centre of its own equal flex cell, which is exactly
   where the track segments begin and end. Neither the dots nor the labels at
   the ends are nudged toward the card edge, and both halves of that are
   deliberate: moving a dot detaches it from the line arriving at it, and
   moving a label parts it from its own dot. On a short ladder the gap is
   glaring, because two rungs means each cell is half the column wide and an
   edge-anchored label ends up a third of the column away from the node it
   names. Cells only get too narrow to hold a centred label in dense mode,
   which is where the anchoring lives instead. */

/* ---- Rank detail -------------------------------------------------------- */
.detail-list { margin: 16px 0 0; }
.detail-list > div {
  display: grid;
  grid-template-columns: 96px minmax(0, 1fr);
  gap: 12px;
  padding: 8px 0;
  border-top: 1px solid var(--border);
}
.detail-list > div:last-child { border-bottom: 1px solid var(--border); }
.detail-list dt {
  margin: 0;
  font-size: var(--step--2);
  font-weight: 700;
  line-height: 1.6;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
}
.detail-list dd {
  margin: 0;
  font-size: var(--step-0s);
  line-height: 1.5;
  color: var(--text-soft);
  font-variant-numeric: tabular-nums lining-nums;
}
.detail-list dd b {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-0l);
  color: var(--text);
}

/* ---- Reserved slot and the unlinked note --------------------------------
   The note is prose, not a row of dashed ghost tiles: nothing collapses to a
   hole and no bar sits at zero. */
.profile-reserved {
  margin: 16px 0 0;
  padding: 24px;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-sm);
  font-family: var(--font-display);
  font-style: italic;
  font-size: var(--step--1);
  text-align: center;
  color: var(--text-muted);
}
.profile-note {
  margin: 0;
  padding: 2px 0 2px 14px;
  border-left: 2px solid color-mix(in srgb, var(--accent-2) 45%, var(--border));
  font-size: var(--step-0s);
  line-height: 1.55;
  color: var(--text-muted);
}
.profile-note b { color: var(--text-soft); font-weight: 700; }

/* ---- The shelf ------------------------------------------------------------
   A featured cover for the standout favourite (the same accent-tinted glow
   .rung-current gives the rank currently held), then the rest of the
   favourites and every liked book as two grids of plain covers at a smaller
   size each. Covers are portrait, 2:3, the shape this site's own book art
   already uses everywhere else it appears. */
.shelf-subhead {
  margin: 24px 0 12px;
  font-size: var(--step--2);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
}
.shelf-featured {
  display: flex;
  align-items: center;
  gap: 16px;
  margin: 16px 0 0;
  padding: 12px;
  border-radius: var(--radius);
  border: 1px solid color-mix(in srgb, var(--accent-2) 30%, var(--border));
  background:
    linear-gradient(160deg, color-mix(in srgb, var(--accent-2) 14%, transparent), transparent 70%),
    var(--surface);
}
.shelf-featured-cover {
  flex: none;
  width: 70px;
  aspect-ratio: 2 / 3;
  border-radius: var(--radius-sm);
  object-fit: cover;
  box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--accent-2) 55%, transparent);
}
.shelf-featured figcaption { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.shelf-featured-tag {
  font-size: var(--step--3);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--accent-ink);
}
.shelf-featured-title {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-0l);
  line-height: 1.3;
  color: var(--text);
}
.shelf-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
  gap: 12px 10px;
  margin: 16px 0 0;
  padding: 0;
  list-style: none;
}
.shelf-grid-liked { grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); }
.shelf-tile { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.shelf-cover {
  display: block;
  width: 100%;
  aspect-ratio: 2 / 3;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  object-fit: cover;
  box-shadow: 0 4px 10px -6px rgba(0, 0, 0, 0.5);
  transition: transform 0.2s var(--ease-out), border-color 0.2s var(--ease), box-shadow 0.2s var(--ease);
}
.shelf-tile:hover .shelf-cover {
  transform: translateY(-2px);
  border-color: var(--border-strong);
  box-shadow: 0 8px 18px -8px rgba(0, 0, 0, 0.6);
}
.shelf-cover-blank {
  background:
    linear-gradient(160deg, color-mix(in srgb, var(--accent-2) 10%, transparent), transparent 65%),
    color-mix(in srgb, var(--text) 6%, var(--surface));
}
.shelf-title {
  font-size: var(--step--3);
  line-height: 1.3;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

/* ---- The private profile ------------------------------------------------
   Everything below is inside a .profile-card, so --profile-pad and
   --accent-ink resolve from the block at the top of this section and the
   private card retints from its own data-accent attribute exactly like the
   public one. That is why there is no per-accent rule here and none is
   needed: the owner's colour still arrives, through the same one attribute,
   on a page that carries nothing else of theirs.

   The card is narrower than the public 1040px because there is one short
   block in it and nothing else, by design. A 1040px card holding four lines
   is an empty room rather than a page, so it takes the reading measure this
   file already trusts for a single column of words. */
.profile-card-private { max-width: var(--reading); }
.profile-private {
  padding: clamp(32px, 6vw, 56px) var(--profile-pad);
  text-align: center;
}
.profile-private-glyph {
  display: inline-grid;
  place-items: center;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  color: var(--accent-ink);
  background: var(--surface-solid);
  box-shadow: 0 0 0 1px var(--accent-ink);
}
.profile-private-glyph svg { width: 26px; height: 26px; }
.profile-private-name {
  margin: 18px 0 0;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--step-2);
  letter-spacing: -0.018em;
  color: var(--text);
  overflow-wrap: anywhere;
}
.profile-private-handle {
  margin: 4px 0 0;
  font-size: var(--step-0s);
  color: var(--text-muted);
}
.profile-private-note {
  margin: 18px auto 0;
  max-width: 42ch;
  font-size: var(--step-0s);
  line-height: 1.55;
  color: var(--text-muted);
}
/* Not part of the card above. This is the quiet owner-only line on the
   ordinary profile, which is the only view an owner ever gets of their own
   private page. */
.profile-private-hint {
  margin: 10px 0 0;
  font-size: var(--step--1);
  color: var(--text-muted);
}

/* ---- Light theme: the measured ceilings ---------------------------------
   Less accent in the mix on a light ground, the same correction this file
   already applies to .pill-verified and for the same reason: Mono's --teal is
   literal white and Reverse's is near it, so a 62% share leaves a pale grey
   mark on a white page. Milestone colours come out of lib/levels.js as
   mid-tone hexes and the yellow-green end (#f5e03a, #d2f03a, #ffffff) needs
   far more --text under it on white than the old 55% gave. The banner
   texture flips too, because a white hairline on a pale band is nothing. */
:root[data-theme='light'] .profile-card {
  --accent-ink: color-mix(in srgb, var(--teal) 40%, var(--text));
  --rank-share: 38%;
  --role-share: 36%;
  --banner-rule: rgba(0, 0, 0, 0.05);
  --banner-sheen: rgba(255, 255, 255, 0.3);
}

/* ---- One column ---------------------------------------------------------
   Banner, avatar still breaking its edge, identity, the sidebar groups, then
   a hairline and the main content. The name keeps --step-3. */
@media (max-width: 899.98px) {
  .profile-body { grid-template-columns: minmax(0, 1fr); column-gap: 0; }
  .profile-side { padding: 0 var(--profile-pad) 24px; }
  .profile-main {
    padding: 24px var(--profile-pad) 32px;
    border-top: 1px solid var(--border);
  }
  .detail-list > div { grid-template-columns: minmax(0, 1fr); gap: 4px; }
}

/* ---- The account page's badge block -------------------------------------
   The same earned row, outside a .profile-card, so the ink it reads has to
   be declared here as well; plus the one hint the public profile never
   shows. */
.badge-field { --accent-ink: color-mix(in srgb, var(--teal) 62%, var(--text)); }
:root[data-theme='light'] .badge-field { --accent-ink: color-mix(in srgb, var(--teal) 40%, var(--text)); }
.badge-field .badge-row { margin-top: 8px; }
.badge-next {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin: 12px 0 0;
  font-size: var(--step--1);
  line-height: 1.4;
  color: var(--text-muted);
}
.badge-next b { color: var(--text-soft); font-weight: 600; }
.badge-ghost {
  flex: none;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: 1px dashed var(--border-strong);
}

.pagination { display: flex; align-items: center; gap: 14px; margin-top: 24px; }
.text-muted { color: var(--text-muted); }

@media (max-width: 640px) {
  /* XP and message count are the two columns to lose on a phone. Level is
     the number people actually compare, and dropping the other two is what
     keeps position, name, rank and level on screen together instead of
     pushing half the table into a sideways scroll. */
  .leaderboard th:last-child,
  .leaderboard td:last-child,
  .leaderboard th:nth-last-child(2),
  .leaderboard td:nth-last-child(2) { display: none; }
  .rank-badge { min-width: 0; flex: 1 1 100%; }
}

/* ==========================================================================
   Chapter rail
   --------------------------------------------------------------------------
   The home page's 01 to 06 eyebrow numerals repeated as a fixed rail in the
   right margin, wide screens only: at 1360px and up there is a real margin
   outside the 1200px shell, and the rail sits in it without ever crowding
   the content edge. Built entirely by site.js (initChapterRail) from the
   sections' own .eyebrow-index elements, so home.ejs carries no second
   copy of the chapter list. Shown once the hero has scrolled away and while
   a chapter is under the middle of the viewport; the active numeral takes
   the eyebrow's own teal and its hairline comes to full strength. Every
   state change here is an opacity or colour change, nothing moves, so the
   global reduced-motion rule (which zeroes these durations) is all it
   needs.
   ========================================================================== */

.chapter-rail {
  display: none;
  position: fixed;
  /* Centred in whatever margin the viewport leaves outside the 1200px
     shell, never closer than 28px to the edge and never so far in that
     it drifts toward the content on very wide screens. */
  right: clamp(28px, calc((100vw - 1200px) / 2 - 40px), 96px);
  top: 50%;
  transform: translateY(-50%);
  z-index: 60;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s var(--ease);
}
.chapter-rail.is-shown { opacity: 1; pointer-events: auto; }
@media (min-width: 1360px) {
  .chapter-rail { display: block; }
}
.chapter-rail ol {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.chapter-rail a {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  min-height: 28px;
  text-decoration: none;
  color: var(--text-muted);
  opacity: 0.55;
  transition: opacity 0.3s var(--ease), color 0.3s var(--ease);
}
.chapter-rail a:hover,
.chapter-rail a:focus-visible { opacity: 0.85; }
.chapter-rail a.is-active { color: var(--teal); opacity: 1; }
.chapter-rail-rule {
  width: 16px;
  height: 1px;
  background: currentColor;
  opacity: 0.4;
  transition: opacity 0.3s var(--ease);
}
.chapter-rail a.is-active .chapter-rail-rule { opacity: 1; }
/* The same italic serif numeral as .eyebrow-index, so the rail reads as
   the section markers themselves gathered into the margin. */
.chapter-rail-index {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 500;
  font-size: var(--step-0);
  line-height: 1;
  min-width: 1.4em;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* ==========================================================================
   Responsive
   ========================================================================== */

@media (max-width: 1080px) {
  .docs-layout { grid-template-columns: 210px minmax(0, 1fr); }
  .docs-toc { display: none; }
}

@media (max-width: 860px) {
  .split.reverse > *:first-child { order: 0; }

  .nav-toggle { display: inline-grid; }

  /* The nav collapses into a panel under the header rather than a full
     screen overlay, so the page behind stays where it was. Kept in the DOM
     and animated via opacity/transform rather than display:none, so the
     hamburger's morph and the panel's own entrance can actually transition
     instead of snapping. */
  /* Solid brand-indigo, not a themed glass panel: it drops out of the same
     gradient bar and reads as a continuation of it, not a second, separately
     themed surface, which is also why its own links stay white rather
     than switching back to the page's light/dark tokens for this one
     collapsed state. */
  .site-nav {
    position: absolute;
    top: 62px;
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    padding: 12px;
    margin: 0;
    background: var(--indigo);
    border-bottom: 1px solid rgba(255, 255, 255, 0.16);
    border-radius: 0 0 var(--radius-lg) var(--radius-lg);
    box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.14);
    opacity: 0;
    visibility: hidden;
    transform: translateY(-8px);
    transition: opacity 0.2s var(--ease-out), transform 0.2s var(--ease-out), visibility 0.2s;
  }
  .site-nav.is-open { opacity: 1; visibility: visible; transform: translateY(0); }
  .site-nav a { height: 44px; font-size: var(--step-0); }
  /* The links cascade in 30ms apart rather than arriving as one block
     with the panel, the same stagger the card grid's reveal uses. The
     delays only exist under .is-open, so on the way closed they all drop
     back to zero and the links fade out together with the panel instead
     of trailing behind it. The base .site-nav a transition (colour and
     background) is repeated here since a transition list replaces rather
     than extends. */
  .site-nav a {
    opacity: 0;
    transform: translateY(-6px);
    transition: color 0.15s var(--ease), background 0.15s var(--ease), opacity 0.22s var(--ease-out), transform 0.3s var(--ease-out);
  }
  .site-nav.is-open a { opacity: 1; transform: none; }
  .site-nav.is-open a:nth-child(2) { transition-delay: 30ms; }
  .site-nav.is-open a:nth-child(3) { transition-delay: 60ms; }
  .site-nav.is-open a:nth-child(4) { transition-delay: 90ms; }
  .site-nav.is-open a:nth-child(5) { transition-delay: 120ms; }
  .site-nav.is-open a:nth-child(6) { transition-delay: 150ms; }
  .nav-flip-row { height: 44px; padding: 0 14px; }
  .site-nav a:hover .nav-flip,
  .site-nav a:focus-visible .nav-flip { transform: translateY(-44px); }

  /* Once the header itself has popped into a fully rounded floating pill
     (.is-stuck), a panel still only rounded on its bottom corners reads as
     a mismatched flap hanging off it. Rounding all four and dropping it
     slightly below the pill instead keeps it reading as its own separate
     card in the same family, matching the gap already opened above the
     header. */
  .site-header.is-stuck .site-nav {
    top: 74px;
    border-radius: var(--radius-lg);
  }

  .docs-layout { grid-template-columns: minmax(0, 1fr); padding-top: 24px; }

  /* The full index is twelve entries. Above the article it buries the
     page you actually opened, so on narrow screens the article comes first
     and the index becomes a footer you scroll to deliberately. */
  .docs-layout > article { order: 1; }
  .docs-sidebar {
    order: 2;
    position: static;
    max-height: none;
    padding-top: 26px;
    margin-top: 8px;
    border-top: 1px solid var(--border);
  }
  .docs-sidebar::before {
    content: 'All documentation';
    display: block;
    font-size: var(--step--2);
    text-transform: uppercase;
    letter-spacing: var(--track-caps);
    color: var(--text-muted);
    font-weight: 700;
    margin-bottom: 4px;
  }
  .footer-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 26px; }
}

@media (max-width: 560px) {
  /* Hidden here for room, which is why the nav panel picks it up below. */
  .header-actions .btn-signin { display: none; }

  .site-nav .nav-account {
    display: block;
    margin-top: 6px;
    padding-top: 13px;
    border-top: 1px solid rgba(255, 255, 255, 0.16);
    color: #fff;
    font-weight: 600;
  }

  .hero-actions .btn { width: 100%; }
  .cta .hero-actions .btn { width: auto; }
  .definition-list dd { text-align: left; }
}

/* Respect the system setting. Everything below either stops or renders a
   single static frame, which is the same approach the app takes. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    transition-delay: 0s !important;
    scroll-behavior: auto !important;
  }
  .has-js .reveal { opacity: 1; transform: none; }
  /* The heading rise, the drawn ticks and the copy tick are transitions
     and one-shot animations, so the duration override above already
     collapses them to their final frame; these settle the start states
     too, so nothing is left a line below or half-drawn for a frame. */
  .has-js .reveal :is(h1, h2, h3) > .rise { transform: none; }
  .has-js .checklist svg path,
  .has-js .auth-benefits svg path,
  .copy-tick path { stroke-dashoffset: 0; }
  /* Ambient loops: the CTA wash holds its first frame and the pending
     dot stays solid, the same way the hero holds one static frame. */
  .cta::before { animation: none; }
  .pill-pending::before { animation: none; }
  /* Badge tooltips and milestone tiles: the duration override above
     already collapses their transitions; these settle the resting states,
     so a tooltip appears in place rather than a frame below it and a tile
     does not lift under the cursor. */
  .badge-tip { translate: 0 0; }
  .milestone:hover { transform: none; }
  /* Search: no panel entrance, no row cascade, and the busy shimmer holds
     as a still line (the fetch state is still shown, it just does not
     travel). */
  .search-results:not(:empty),
  .search-result,
  .search:has(.search-input[aria-busy='true'])::after { animation: none; }
  .search:has(.search-input[aria-busy='true'])::after { background-size: 100% 100%; background-position: 0 0; }
  /* A scroll-driven animation has no duration to shorten, the timeline is
     the scroll position itself, so the duration override above does not
     stop it; only removing the animation does. The hero's depth wrapper
     then just sits still, the way it does in browsers without scroll
     timelines. The fabric's rack-focus blur is a transition and would
     collapse on its own, but if script never lands is-fabric-active the
     canvas is invisible anyway, so the blur is dropped outright here. The
     404 word grid draws one static frame from script (see initErrorWords)
     and needs nothing from this block. */
  .hero-atmosphere-depth { animation: none; }
  .hero-fabric { filter: none; }
  /* The reading-progress hairline is the opposite case: scroll-tracking,
     not autonomous motion, it only ever moves as far as the visitor
     scrolls, so its scroll timeline stays. Restoring the default duration
     keeps the mapping from resting on the subtlety above (a scroll
     timeline maps onto whatever total time the animation has, so the
     0.001ms would in fact still work); browsers without scroll timelines
     drop the auto keyword and use the site.js --progress fallback. The
     sliding TOC marker and the carried header pill are transitions and
     are already collapsed by the duration override. */
  .read-progress { animation-duration: auto !important; }
  /* The public profile card's only motion is the shared .reveal entrance,
     already settled above, and the milestone tile's hover lift. The duration
     override at the top of this block collapses that transition, but a
     collapsed transform transition still settles on its transformed end
     state, so the lift has to be removed outright rather than shortened.
     Nothing on that page is a scroll-driven timeline or an infinite loop, so
     nothing there needs an animation: none. */
  .milestone:hover { transform: none; }
}

/* The secondary button is the one remaining surface that uses
   backdrop-filter as material rather than a one-off accent, the header
   itself is already a solid, opaque gradient with no blur to reduce, so it
   no longer needs a Reduced Transparency fallback of its own. */
@media (prefers-reduced-transparency: reduce) {
  .btn-secondary {
    background: var(--surface-solid);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

/* Increased Contrast is a different ask than Reduced Transparency: even a
   solid, opaque gradient can still fail a real contrast check for some of
   its own four stops, so this is the one place the brand-gradient header
   design gives way to a guaranteed-legible flat surface. Everything
   inside it that was hardcoded to white for the gradient has to be handed
   back to the ordinary theme tokens here too, or it would stay white text
   on top of this override's own light-theme background. */
@media (prefers-contrast: more) {
  .site-header {
    background: var(--bg);
    border-bottom: 1px solid var(--text);
  }
  .wordmark span { color: var(--text); text-shadow: none; }
  .wordmark .mark { box-shadow: none; }
  .site-nav a { color: var(--text-soft); }
  .site-nav a:hover, .site-nav a[aria-current='page'] { color: var(--text); background: var(--surface); }
  .site-nav a::after { background: var(--text); }
  .icon-button { border-color: var(--border); background: var(--surface); color: var(--text-soft); }
  .icon-button:hover { color: var(--text); border-color: var(--border-strong); }
  .theme-toggle { background: var(--surface); border-color: var(--border-strong); }
  .search-input { border-color: var(--border-strong); background: var(--surface-solid); color: var(--text); }
  .search-input::placeholder { color: var(--text-muted); }
  .site-header .btn-ghost { color: var(--text-soft); }
  .site-header .btn-ghost:hover { color: var(--text); background: var(--surface); }
  .site-header .btn-secondary,
  .site-header .btn-primary {
    background: var(--surface-solid);
    color: var(--text);
    border-color: var(--text);
    box-shadow: none;
  }
}

@media print {
  .site-header, .site-footer, .docs-sidebar, .docs-toc, .doc-pager { display: none; }
  body { background: #fff; color: #000; }
  .prose { max-width: none; }
}

/* ==========================================================================
   Cross-document view transitions
   --------------------------------------------------------------------------
   This is a server-rendered, no-JS-framework site, every link is a real
   navigation, not a client-side route swap. The View Transitions API is
   what makes that still feel like one continuous app instead of a hard
   reload between pages, entirely as browser-native CSS: no router, no
   diffing, no script at all. Unsupported browsers (anything not on a
   recent Chromium or Safari) just ignore the whole @view-transition rule
   and get the plain instant navigation they always had. There is no
   fallback to write because there is nothing to fall back from.
   ========================================================================== */

@view-transition {
  navigation: auto;
}

/* The header is the one thing that should not visibly participate in the
   crossfade, it is identical across every page, so naming it pulls it out
   of the whole-page snapshot and lets it just persist, instead of fading
   out and back in on every single navigation. */
.site-header { view-transition-name: site-header; }
::view-transition-old(site-header),
::view-transition-new(site-header) {
  animation: none;
  mix-blend-mode: normal;
}

/* A soft cross-fade with a slight vertical settle, not a slide or a wipe --
   this site's own animations (.reveal, the hero entrance) are all fades
   with a small translate, so the transition between pages should feel like
   the same hand drew it, not a separate effect bolted on. */
::view-transition-old(root) {
  animation: view-transition-out 0.32s var(--ease) both;
}
::view-transition-new(root) {
  animation: view-transition-in 0.38s var(--ease-out) both;
}
@keyframes view-transition-out {
  to { opacity: 0; transform: translateY(-8px) scale(0.99); }
}
@keyframes view-transition-in {
  from { opacity: 0; transform: translateY(10px) scale(0.99); }
}

/* The blog's tone badge travels from its index card to the post header
   (both carry view-transition-name: post-<slug>, see the two views, and
   .post-icon gives every one the post-badge class so this single rule
   times all of them). A touch longer than the page crossfade and on the
   site's own ease-out, so the badge lands just as the article settles. */
::view-transition-group(.post-badge) {
  animation-duration: 0.38s;
  animation-timing-function: var(--ease-out);
}

/* Direction of travel. The docs pager and the post nav are the site's only
   sequential structures, and back/forward is the browser's own; site.js
   types the transition "forward" or "back" for those (via pageswap and
   pagereveal) and the settle turns sideways: a 12px shift, not a slide,
   at the same durations and easings as the vertical settle above, which
   is why only animation-name changes here. Anything untyped, and any
   browser without typed transitions, keeps the vertical settle. */
html:active-view-transition-type(forward)::view-transition-old(root) { animation-name: view-transition-out-forward; }
html:active-view-transition-type(forward)::view-transition-new(root) { animation-name: view-transition-in-forward; }
html:active-view-transition-type(back)::view-transition-old(root) { animation-name: view-transition-out-back; }
html:active-view-transition-type(back)::view-transition-new(root) { animation-name: view-transition-in-back; }
@keyframes view-transition-out-forward {
  to { opacity: 0; transform: translateX(-12px) scale(0.99); }
}
@keyframes view-transition-in-forward {
  from { opacity: 0; transform: translateX(12px) scale(0.99); }
}
@keyframes view-transition-out-back {
  to { opacity: 0; transform: translateX(12px) scale(0.99); }
}
@keyframes view-transition-in-back {
  from { opacity: 0; transform: translateX(-12px) scale(0.99); }
}

/* The theme and palette switch, run by site.js as a same-document
   transition typed "theme". A colour change has no direction and nothing
   moves, so every snapshot, the header included, plays a plain opacity
   crossfade instead of the navigation settle: (*) covers the root and
   every named element at once, and plus-lighter is what keeps a
   crossfade between two opaque images from dipping in the middle. */
html:active-view-transition-type(theme)::view-transition-old(*) {
  animation: view-transition-fade-out 0.3s var(--ease) both;
  mix-blend-mode: plus-lighter;
}
html:active-view-transition-type(theme)::view-transition-new(*) {
  animation: view-transition-fade-in 0.3s var(--ease) both;
  mix-blend-mode: plus-lighter;
}
@keyframes view-transition-fade-out {
  to { opacity: 0; }
}
@keyframes view-transition-fade-in {
  from { opacity: 0; }
}

/* Shared elements. Each doc's banner is named per slug in the two docs
   views (doc-banner-<slug>, on the index card and on the page), so opening
   a guide morphs the card's banner into the page's rather than
   crossfading two differently sized gradients. The class is what lets
   one rule address all of those names: both snapshots stretch to fill
   the group as it resizes (the captured images would otherwise keep
   their own aspect ratios and expose a corner), and the card's banner
   rounds its own top corners so the snapshot carries them, since the
   parent card's overflow clip is not part of what gets captured. */
.doc-card-banner,
.doc-page-banner { view-transition-class: doc-banner; }
.doc-card-banner { border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
::view-transition-group(.doc-banner) {
  animation-duration: 0.4s;
  animation-timing-function: var(--ease-out);
}
::view-transition-old(.doc-banner),
::view-transition-new(.doc-banner) {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The docs rails hold still while the article crossfades between two
   docs, the header's own pattern above; :only-child is the case where a
   rail exists on one side only (the docs index has no rails), and then
   it fades in or out with the page instead of popping. Below 860px the
   sidebar is no longer a rail but a footer under the article, at a
   different height on every doc, so it goes back into the root snapshot
   there rather than flying between positions. */
.docs-sidebar { view-transition-name: docs-sidebar; }
.docs-toc { view-transition-name: docs-toc; }
::view-transition-old(docs-sidebar),
::view-transition-new(docs-sidebar),
::view-transition-old(docs-toc),
::view-transition-new(docs-toc) {
  animation: none;
  mix-blend-mode: normal;
}
::view-transition-old(docs-sidebar):only-child,
::view-transition-old(docs-toc):only-child { animation: view-transition-out 0.32s var(--ease) both; }
::view-transition-new(docs-sidebar):only-child,
::view-transition-new(docs-toc):only-child { animation: view-transition-in 0.38s var(--ease-out) both; }
@media (max-width: 860px) {
  .docs-sidebar { view-transition-name: none; }
}

/* The gliding indicators: the sidebar's current-page highlight, the
   leaderboard's active period tab and the blog's active tag are each one
   element per page, so a fixed name is enough, and the default group
   animation carries them from the old position to the new one. Named
   inside the held-still sidebar is fine: a named descendant is captured
   on its own and left out of its parent's snapshot. */
.docs-sidebar a[aria-current='page'] { view-transition-name: docs-active; }
.period-tab.is-active,
.tag.is-active { view-transition-name: active-filter; }
::view-transition-group(docs-active),
::view-transition-group(active-filter) {
  animation-duration: 0.35s;
  animation-timing-function: var(--ease-out);
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

/* ==========================================================================
   Community invite

   Three placements, one shape: the leaderboard's button, the footer's
   Community column and the home page's closing line all use the same
   Discord mark and the same wording, so they read as one invitation shown
   at three volumes rather than three separate asks.
   ========================================================================== */

.discord-icon {
  width: 17px;
  height: 17px;
  flex-shrink: 0;
}

/* The leaderboard's version: the loudest, because it sits on the page that
   is already showing the server's own scoreboard. The note beside it says
   what joining actually gets you rather than repeating the label. */
.join-row {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
  margin: 0 0 26px;
}
.join-note {
  color: var(--text-muted);
  font-size: var(--step--1);
  line-height: 1.4;
  max-width: 34ch;
}
/* The gradient sweep on .btn-primary reads as a highlight passing over a
   surface; on a control carrying someone else's logo it looked like the
   logo itself was catching light, so this one keeps the lift and the glow
   and drops the sweep. */
.join-discord::after { display: none; }

/* The footer's version: a plain column link that happens to carry the mark,
   sized and coloured exactly like its neighbours so the column stays a list
   of links rather than becoming a banner. */
.footer-discord {
  display: inline-flex;
  align-items: center;
  gap: 7px;
}
.footer-discord .discord-icon { width: 15px; height: 15px; }

/* The home page's version: the quietest, a line of text under the closing
   call to action. .text-link already supplies the underline and the colour,
   so this only has to place the mark and keep the line from crowding the
   buttons above it. */
.cta-community {
  margin: 18px 0 0;
  color: var(--text-muted);
  font-size: var(--step-0s);
}
.cta-community .text-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-left: 4px;
}

/* ---- Creator panel -------------------------------------------------------
   The admin console: a search/list page and a per-account detail page,
   both built entirely from classes already defined for the rest of the
   site (.panel, .field, .checkbox, .pill, .role-mark, .leaderboard, .select)
   rather than a parallel vocabulary of their own. Only what genuinely has
   no existing home gets a rule here. */
.flex-one { flex: 1 1 200px; }

.admin-search {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 12px;
}
.admin-search .field-label { margin-bottom: 6px; }
.admin-search .input { min-width: 240px; flex: 1 1 240px; }

/* The reader cell's own two-line stack (handle, then display name below it
   in a quieter weight). .leaderboard-reader/.avatar-sm already give the
   row its avatar-plus-name shape and its no-underline-until-hover link
   treatment (see that class's own comment on the reading-time board,
   which this reuses verbatim). */
.admin-reader-name { display: block; font-weight: 600; color: var(--text); }
.admin-reader-sub { display: block; font-size: var(--step--2); color: var(--text-muted); font-weight: 400; }

/* A standalone verified tick for a table cell of its own. Everything that
   draws it, the eight-lobed mask, the gradient, the sheen. Is shared
   with .profile-tick up in the profile block; all this adds is a fixed px
   size, since nothing here sits inline with text it has to scale against.
   Deliberately not animated: a table of these all stamping themselves in at
   once would be noise, and the badge is a data point in a row here rather
   than the flourish beside a name. */
.admin-check {
  width: 22px;
  height: 22px;
}

/* ---- Account standing: the read-only glance row -------------------------
   Role, verified tick and every tag, all at once, above the forms that
   edit them, the same "here is what is true" summary the public profile
   itself draws, so a creator sees the account the way everyone else does
   before changing anything about it. */
.standing-summary {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 14px;
  margin-bottom: 22px;
  padding-bottom: 22px;
  border-bottom: 1px solid var(--border);
}
.standing-summary .role-row { margin: 0; }
.standing-empty { color: var(--text-muted); font-size: var(--step--1); font-style: italic; }

.admin-subhead {
  font-size: var(--step--1);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--text-muted);
  margin: 24px 0 12px;
}
.admin-subhead:first-child { margin-top: 0; }

/* Tags: a checkbox grid rather than the accent-swatch flex-wrap it borrowed
   space from before. Fixed-width cards line up cleanly regardless of how
   many tags exist, which matters once the creator can add more of their
   own (see lib/tag-definitions.js) instead of the dozen this shipped with. */
.tag-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 10px;
  margin: 14px 0;
}
.tag-checkbox {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: color-mix(in srgb, var(--bg) 25%, transparent);
  font-size: var(--step--1);
  color: var(--text-soft);
  cursor: pointer;
  transition: border-color 0.15s var(--ease), background 0.15s var(--ease);
}
.tag-checkbox:has(input:checked) {
  border-color: color-mix(in srgb, var(--violet) 50%, var(--border));
  background: color-mix(in srgb, var(--violet) 12%, transparent);
  color: var(--text);
}
.tag-checkbox input { margin: 0; width: 16px; height: 16px; accent-color: var(--violet); flex-shrink: 0; }

.admin-toggles { display: flex; flex-direction: column; gap: 18px; }

.admin-media { display: flex; gap: 28px; flex-wrap: wrap; margin-bottom: 18px; }
.admin-media-block { min-width: 0; }
.admin-media-banner { flex: 1 1 280px; }
.admin-media-banner img { margin-top: 8px; width: 100%; max-width: 420px; border-radius: var(--radius); display: block; aspect-ratio: 4 / 1; object-fit: cover; }
.admin-media-actions { display: flex; gap: 12px; flex-wrap: wrap; }

/* ---- Tag registry (creator panel) ---------------------------------------
   The create form and the list below it read like the rest of this panel --
   .field/.input/.select, one .panel each. Rather than a second visual
   language just for this page. */
.tag-create-form {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 4px 20px;
  align-items: start;
}
.tag-create-wide { grid-column: 1 / -1; }
.tag-create-form > .btn { grid-column: 1 / -1; justify-self: start; margin-top: 8px; }

.tag-colour-input {
  width: 100%;
  height: 42px;
  padding: 4px;
  border-radius: 11px;
  border: 1px solid color-mix(in srgb, #fff 14%, var(--border-strong));
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  cursor: pointer;
}
.tag-colour-input::-webkit-color-swatch-wrapper { padding: 0; }
.tag-colour-input::-webkit-color-swatch { border: none; border-radius: 7px; }

.tag-def-list { display: flex; flex-direction: column; list-style: none; padding: 0; margin: 0; }
.tag-def-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 10px 14px;
  padding: 14px 2px;
  border-bottom: 1px solid var(--border);
}
.tag-def-row:last-child { border-bottom: none; }
/* A colour preview only, nothing on the site tints a tag by tone_hex yet,
   so this dot is a promise of what the colour will look like once something
   does, not a claim that it already renders anywhere else. */
.tag-def-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  margin-top: 5px;
  flex-shrink: 0;
  background: var(--tag-tone, var(--violet));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, #000 25%, transparent);
}
.tag-def-main { flex: 1 1 220px; min-width: 0; }
.tag-def-label { font-weight: 600; }
.tag-def-key { font-weight: 400; color: var(--text-muted); font-size: var(--step--1); margin-left: 4px; }
.tag-def-desc { color: var(--text-muted); font-size: var(--step--1); margin-top: 3px; }
.tag-def-glyph { color: var(--text-muted); font-size: var(--step--2); margin-top: 3px; text-transform: uppercase; letter-spacing: var(--track-caps); }
.tag-def-remove {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-shrink: 0;
  margin-left: auto;
  padding-top: 2px;
}
.tag-def-force { font-size: var(--step--2); white-space: nowrap; }
.tag-def-force input { width: 15px; height: 15px; margin: 0; }

/* Patreon tier editor: five .tag-def-row rows, each one whole edit form
   (label + colour + save) rather than a create form above and a remove
   form beside a static row. There is nothing to create or remove here,
   see 20260906000006_patreon_tier_display.sql for why, only five fixed
   rows to retint. The form itself carries the row's flex layout since it
   is now the row's one direct child. */
.patreon-tier-form { display: flex; align-items: flex-start; gap: 14px; width: 100%; flex-wrap: wrap; }
.patreon-tier-fields { display: flex; gap: 10px; margin-top: 6px; max-width: 320px; }
.patreon-tier-fields .input { flex: 1 1 auto; min-width: 0; }
.patreon-tier-fields .tag-colour-input { flex: 0 0 52px; width: 52px; }

/* ---- Account page: one cover composition for picture + banner ----------
   The avatar overlaps the banner's bottom-left corner exactly the way
   /u/<username> itself draws the two (see .profile-banner/.profile-avatar-wrap
   above). Editing them as two disconnected sections never looked like the
   thing being edited; showing them exactly as they will actually appear,
   with the change control sitting right on each, does. Deliberately a
   simpler build than that public version (no texture/sheen/scrim layers):
   those exist to keep a large hero band from reading as a flat slab on Void
   and Mono, which is not a problem a compact editing preview has. */
.profile-cover-edit {
  position: relative;
  margin-bottom: 44px;
}
.profile-cover-banner {
  position: relative;
  width: 100%;
  /* 4 / 1, tied to .profile-banner and to BANNER_W / BANNER_H in
     public/js/site.js. This preview exists to show the reader exactly what
     /u/<username> will show, which it was not doing while it rendered 3 / 1
     against a 4:1 stored image: the preview cropped the sides, and so did
     the real page, so the crop tool was the only place the framing was ever
     truthful. All three now agree. */
  aspect-ratio: 4 / 1;
  max-height: 220px;
  border-radius: var(--radius);
  overflow: hidden;
  border: 1px solid var(--border);
  background-image: linear-gradient(135deg, var(--violet), var(--teal));
}
.profile-cover-banner-image {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.profile-cover-avatar {
  position: absolute;
  left: 24px;
  bottom: -32px;
  width: 88px;
  height: 88px;
  border-radius: 50%;
  border: 4px solid var(--bg);
  overflow: hidden;
  background: var(--surface-solid);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.profile-cover-avatar-image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* Both controls sit absolutely over the composition rather than beside it,
   revealed by site.js exactly like the two-section version this replaced
   (hidden is only ever removed by script, since the crop needs a canvas a
   no-script visitor cannot run). */
.profile-cover-control-avatar {
  position: absolute;
  left: 24px;
  bottom: -32px;
  width: 88px;
  height: 88px;
  display: flex;
  align-items: flex-end;
  justify-content: flex-end;
  padding: 4px;
}
.profile-cover-control-banner {
  position: absolute;
  right: 12px;
  bottom: 12px;
}
.profile-cover-edit-btn {
  display: inline-grid;
  place-items: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--bg) 65%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  border: 1px solid color-mix(in srgb, #fff 20%, var(--border));
  color: var(--text);
  cursor: pointer;
  transition: background 0.15s var(--ease), transform 0.15s var(--ease);
}
.profile-cover-edit-btn:hover { background: var(--violet); color: var(--on-brand); transform: scale(1.06); }
.profile-cover-edit-btn svg { width: 16px; height: 16px; }
.profile-cover-control-avatar .profile-cover-edit-btn { width: 28px; height: 28px; }
.profile-cover-control-avatar .profile-cover-edit-btn svg { width: 14px; height: 14px; }

.profile-cover-remove { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }

/* ---- Image crop modal ----------------------------------------------------
   Opens between "file chosen" and "upload" for both the avatar and banner
   uploaders (public/js/site.js's openImageCropModal, shared by both) so a
   picture is framed on purpose instead of always landing on an automatic
   centre-crop the person never saw coming. z-index 500 is comfortably above
   this site's own highest ordinary layer (.skip-link's 200); nothing else on
   the page is meant to be reachable while this is open. */
.crop-modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 500;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(4, 4, 9, 0.72);
  backdrop-filter: blur(6px) saturate(1.1);
  -webkit-backdrop-filter: blur(6px) saturate(1.1);
}
.crop-modal {
  width: min(640px, 100%);
  max-height: 100%;
  overflow-y: auto;
  border-radius: var(--radius-lg);
  background: var(--surface-solid);
  border: 1px solid var(--border-strong);
  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
  padding: 28px;
  text-align: center;
}
.crop-modal-title { font-family: var(--font-display); font-size: var(--step-1); margin-bottom: 0.3em; }
.crop-modal-hint { color: var(--text-muted); font-size: var(--step--1); margin-bottom: 20px; }

/* The stage is deliberately bigger than the crop frame it holds, the
   spillover outside the frame stays visible, just dimmed, so dragging the
   photo around has real spatial context instead of the frame acting like a
   hard clip nothing exists beyond. */
.crop-modal-stage {
  position: relative;
  margin: 0 auto 18px;
  width: 100%;
  /* A fixed height regardless of which aspect ratio is being cropped --
     see openImageCropModal's own comment on why sizing this to match the
     frame's shape would leave a banner crop with no margin to drag
     through on one axis. min() keeps it from overwhelming a short
     viewport the way a plain vh value could. */
  height: min(52vh, 420px);
  border-radius: var(--radius);
  overflow: hidden;
  background: #000;
  cursor: grab;
  touch-action: none;
  user-select: none;
}
.crop-modal-stage.is-dragging { cursor: grabbing; }
.crop-modal-image {
  position: absolute;
  top: 0; left: 0;
  transform-origin: top left;
  max-width: none;
  pointer-events: none;
  will-change: transform;
}
/* The classic "dim everything outside this box" trick: a spread big enough
   to blanket the whole stage, using the frame's own box-shadow instead of a
   second full-size overlay element that would need its own clip-path to
   punch a hole for the frame (and to follow a circular frame's shape,
   border-radius on the box-shadow source does that for free). */
.crop-modal-frame {
  position: absolute;
  border: 2px solid rgba(255, 255, 255, 0.85);
  box-shadow: 0 0 0 9999px rgba(4, 4, 9, 0.68);
  pointer-events: none;
  transition: opacity 0.15s var(--ease), border-color 0.15s var(--ease);
}
.crop-modal-frame.is-circle { border-radius: 50%; }
/* The preview toggle drops the dimming and the frame outline so the person
   can see the full original photo underneath, exactly the "does this need
   the whole eye toggle" question a plain crop rectangle can't answer on its
   own, it does not change what will actually be saved, only what shows. */
.crop-modal-stage.is-previewing .crop-modal-frame { opacity: 0; }

.crop-modal-zoom-row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 0 auto 22px;
  max-width: 360px;
}
.crop-modal-zoom-row svg { width: 16px; height: 16px; color: var(--text-muted); flex-shrink: 0; }
.crop-modal-zoom {
  flex: 1 1 auto;
  accent-color: var(--violet);
  cursor: pointer;
}

.crop-modal-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}
.crop-modal-actions-left,
.crop-modal-actions-right { display: flex; align-items: center; gap: 8px; }

.btn-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  border: 1px solid var(--border-strong);
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  color: var(--text);
  cursor: pointer;
  transition: background 0.15s var(--ease), border-color 0.15s var(--ease), opacity 0.15s var(--ease);
}
.btn-icon svg { width: 17px; height: 17px; }
.btn-icon:hover:not(:disabled) { background: color-mix(in srgb, var(--violet) 18%, var(--bg)); border-color: var(--violet); }
.btn-icon:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-icon[aria-pressed='true'] { background: var(--violet); border-color: var(--violet); color: var(--on-brand); }

@media (max-width: 560px) {
  .crop-modal { padding: 20px; }
  .crop-modal-actions { justify-content: center; }
  .crop-modal-actions-right { width: 100%; }
  .crop-modal-actions-right .btn { flex: 1 1 auto; }
}
