/* Core, then every shipped functional family in cascade order. Typography
   precedes Layout because direct-child layout resets intentionally win over
   equal-specificity prose flow margins. Forms precede Components because of
   join. Do not alphabetize these top-level imports. */
/* Core — the minimal Actual language. Every module depends on this order. */
/* Mild reset: useful defaults without classless magic. */
*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
}

button,
input,
textarea,
select {
  font: inherit;
}

[hidden]:not([hidden="until-found"]) {
  display: none !important;
}

:where(img, svg, video) {
  max-inline-size: 100%;
  block-size: auto;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto !important;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-delay: 0s !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}

:root {
  /*
   * Global framework tokens.
   *
   * Public: properties in the named groups below are public API (typography,
   * spacing, shape, motion, focus, controls, viewport, z-index, icons) unless
   * a group is explicitly marked Internal or Derived.
   *
   * Derived: "Theme-derived aliases" and "State tokens" reference intent and
   * surface tokens and follow the theme; do not hard-code values into them.
   * Internal: indicator offsets, --disabled-opacity and the like are
   * consumed by components, not authored.
   *
   * Component-local hooks and internal state relays live with their
   * components. See docs/pages/foundations/tokens.md for the public/internal
   * token contract.
   */

  /* Typography */
  --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --line-height: 1.5;
  --line-height-tight: 1.25;
  --line-height-relaxed: 1.75;
  --font-weight-normal: 400;
  --font-weight-medium: 500;
  --font-weight-semibold: 600;
  --font-weight-bold: 700;

  /* Typography scale */
  --font-size-xs: 0.8125rem;
  --font-size-sm: 0.875rem;
  --font-size-md: 1rem;
  --font-size-lg: 1.125rem;

  /* Spacing — official steps are named in tens so the scale stays open.
     Actual CSS will never define a --space-* that is not a multiple of 10:
     the intermediate names (--space-15, --space-45…) are reserved for
     applications, and no future framework step can collide with them. */
  --gap: 0.75rem;
  --density-space: var(--space-40);
  --space-10: 0.25rem;
  --space-20: 0.5rem;
  --space-30: 0.75rem;
  --space-40: 1rem;
  --space-50: 1.5rem;
  --space-60: 2rem;

  /* Shape */
  --border-width: 1px;
  --radius-full: 9999px;
  --shadow: 0 1px 3px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.04);
  --shadow-popout: 0 0.25rem 0.75rem rgb(0 0 0 / 0.15);

  /* Motion — see reset.css for the prefers-reduced-motion hard reset.
     --duration scales component transitions (hover/focus); the dedicated
     animation rates below tune one-shot open/close and decorative loops.
     Generic state/interaction motion uses the CSS default `ease` directly
     (registered nowhere). --ease-enter/--ease-exit express the character of
     one-shot presence changes, owned by the open/closed state rules that
     separate them (status-bar, drawer, modal). The pair follows the standard
     M3 rhythm; the more aggressive emphasized-decelerate entrance is
     deliberately not offered because it only pays off on 250–400ms motions,
     beyond this vocabulary. Toggles that share one transition list at
     --duration-fast (flyout, tooltip) and pure-opacity backdrops stay on
     `ease`: at 100ms the curve has almost no resolving time, and a scrim is
     not traveling anywhere. Animation policy: reset.css is the global safety
     net; every meaningful @keyframes is explicitly gated behind
     prefers-reduced-motion: no-preference; ordinary transitions rely on the
     reset alone. */
  --duration: 150ms;
  --duration-fast: 100ms;
  --duration-slow: 200ms;
  --duration-spin: 0.75s;
  --duration-shimmer: 1.2s;
  --ease-enter: cubic-bezier(0.2, 0, 0, 1);
  --ease-exit: cubic-bezier(0.4, 0, 1, 1);

  /* Focus ring — shared by every interactive component's :focus-visible */
  --focus-outline-color: currentColor;
  --focus-outline: calc(var(--border-width) * 2) solid var(--focus-outline-color);
  --focus-outline-offset: 2px;
  --focus-ring-width: 3px;

  /* Status dot positioning — used by .avatar > .badge:empty. The offset is
     the circle-aware inset (1 - 1/√2) / 2 ≈ 14.65%; the ring surrounds the
     dot with the parent surface color. */
  --indicator-offset: 14.65%;

  /* Shared backdrop — consumed by modal, drawer, and mobile flyout sheets. */
  --backdrop-color: rgb(0 0 0);
  --backdrop-opacity: 0.45;
  --backdrop-fill: rgb(0 0 0 / var(--backdrop-opacity));

  /* Bar thickness shared by .progress and .meter — two conceptual variants of
     the same geometry. Public: set it on the element or on an application
     class rather than reaching for a size variant. */
  --bar-height: 0.65rem;

  /* Controls — inputs and buttons share --control-size so heights always match.
     .compact/.spacious (variants.css) switch geometry without changing
     --control-font-size. Local .sm/.lg component sizes switch all three.

     Inline choices (checkbox, radio, switch) are typographic, not field-sized:
     they sit on a line of text, so they follow --control-font-size through
     --choice-control-size instead of --control-size. Being an em value in
     :root is deliberate — an unregistered custom property resolves its units
     at the element that consumes it, so one declaration tracks whatever font
     size the choice actually renders at.

     --control-pad-x is the opposite kind of token: a resolved geometry length,
     not an em ratio. Every part of one control must see the same horizontal
     distance, so it is stated in the typographic tokens rather than an em
     whose value would change on a descendant with its own font size (the
     input-icon glyph, made larger by --input-icon-size). It mirrors
     --control-font-size and is rebound beside it at the .sm/.lg boundaries
     (variants.css); declaring it only in :root would freeze the md value,
     because a var() inside a custom property resolves where it is declared. */
  --control-size-sm: 2rem;
  --control-size-md: 2.375rem;
  --control-size-lg: 2.75rem;
  --control-size: var(--control-size-md);
  --control-pad-x: var(--font-size-md);
  --control-font-size: var(--font-size-md);
  --choice-control-size: 1.125em;
  --disabled-opacity: 0.65;
}

/* Theme-derived aliases. These reference intent/surface tokens, so they must
   be re-declared on every theme boundary: a custom property resolves its
   var() references at computed-value time on the element that declares it,
   so an alias declared only on :root is inherited as an already-resolved
   value and would not follow a [data-theme] island's overridden tokens.
   Themes that want a distinct alias override it explicitly afterwards. */
:root,
[data-theme] {
  /* State tokens — semantic colors for selected/pressed/current and disabled.
     Components consume these instead of hard-wiring --primary/--text-subtle
     so forced-colors can override them in one place. */
  --state-selected: var(--primary);
  --state-selected-fg: var(--primary-fg);
  --state-disabled: var(--text-subtle);

  /* Status dot ring — follows the island's surface. */
  --indicator-ring: var(--surface);
}

:root {
  /* Variant tuning — themes may adjust generated soft surfaces without
     replacing the component rules. */
  --soft-bg-mix: 88%;
  --soft-border-mix: 65%;
  --soft-hover-alpha: 12%;
  /* Soft foreground: the share of raw intent in soft text. 100% keeps the
     pure intent color; lower values rebate it toward --text, which vivid or
     light intent palettes need to stay legible on their own soft surface.
     The default palette ships per-role --*-soft-fg hooks calibrated against
     the hovered soft fill instead (theme.css); this mix is the fallback a
     theme without hooks falls back to. */
  --soft-fg-mix: 100%;

  /* Shared icon assets. Override these URLs when a strict CSP disallows data:. */
  --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%238a8594' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
  --icon-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
  --icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3C/svg%3E");
  --icon-plus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='12' y1='5' x2='12' y2='19'/%3E%3Cline x1='5' y1='12' x2='19' y2='12'/%3E%3C/svg%3E");

  /* Viewport size tokens — fallback vh/vw with dvh/vi enhancement.
     Consumed by full-viewport components (app-shell, drawer, modal, flyout).
     Custom properties keep the fallback explicit while allowing modern units
     to enhance the same public tokens. */
  --viewport-block: 100vh;
  --viewport-inline: 100vw;

  /* Z-index scale — orders document-layer components only. Native top-layer
     elements, dialogs and popovers alike, do not participate in this ordering:
     they always paint above the document, and their order among themselves is
     their top-layer promotion order. No z-index value here can reach them.
     Actual CSS reserves multiples of 10 for framework layers; applications may
     use intermediate values for local layering. */
  --z-sticky: 10;
  --z-menu: 20;
  --z-status: 60;
}

@media (forced-colors: active) {
  :root,
  [data-theme] {
    --state-disabled: GrayText;
    --disabled-opacity: 1;
  }
}

@supports (block-size: 100dvh) {
  :root {
    --viewport-block: 100dvh;
  }
}

@supports (inline-size: 100vi) {
  :root {
    --viewport-inline: 100vi;
  }
}

@supports (color: color-mix(in oklch, red, white)) {
  :root,
  [data-theme] {
    /* Shadows derive from --shadow-color (theme.css) — re-declared on every
       theme boundary so islands follow their own shadow-color. */
    --shadow:
      0 1px 3px color-mix(in oklch, var(--shadow-color) 5%, transparent),
      0 4px 12px color-mix(in oklch, var(--shadow-color) 4%, transparent);
    --shadow-popout: 0 0.25rem 0.75rem color-mix(in oklch, var(--shadow-color) 15%, transparent);
  }

  /* Theme-derived like the shadows above, so it is declared on the same
     boundaries: an alias resolved only on :root is inherited already composed,
     and a [data-theme] island overriding --backdrop-color or
     --backdrop-opacity would not change the scrim of a dialog inside it. */
  :root,
  [data-theme] {
    --backdrop-fill: color-mix(
      in oklch,
      var(--backdrop-color) calc(var(--backdrop-opacity) * 100%),
      transparent
    );
  }
}

/*
 * Theme tokens — the palette, surfaces, text, borders, radius and shadow
 * color. Named themes (themes/*.css) override this file; authors can set the
 * same tokens on :root or on [data-theme="…"] islands.
 *
 * Public: Intent colors, Surfaces, Text, Lines and focus, Radius scale,
 * Shadow color, Focus ring derivation.
 * Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
 * other tokens; hard-coded overrides on a custom theme will break the intent.
 * Internal: --hgroup-gap.
 */
:root {
  color-scheme: light dark;
}

[data-theme="light"] {
  color-scheme: light;
}

[data-theme="dark"] {
  color-scheme: dark;
}

/* Dark mode is progressive enhancement via light-dark(). Older browsers get
   the light fallback rather than a partially dark native color scheme over
   light tokens. */
@supports not (color: light-dark(red, blue)) {
  :root,
  [data-theme="light"],
  [data-theme="dark"] {
    color-scheme: light;
  }
}

/* Default palette — "ink & terra": aubergine primary, terracotta secondary,
   pigment-toned statuses (sage, ochre, brick) tuned to equal perceived weight.
   The neutral ramp (text, borders, subtle surfaces) is tinted toward the
   primary hue (~260) so the whole page carries the identity, quietly. */

/* Light fallback — also the default on browsers without light-dark() support. */
:root,
[data-theme="light"] {
  /* Intent colors */
  --primary: hsl(268 32% 33%);
  --primary-fg: hsl(0 0% 98%);

  --secondary: hsl(16 38% 38%);
  --secondary-fg: hsl(0 0% 98%);

  --success: hsl(150 40% 31%);
  --success-fg: hsl(0 0% 98%);

  --warning: hsl(36 68% 34%);
  --warning-fg: hsl(0 0% 98%);

  --danger: hsl(5 58% 43%);
  --danger-fg: hsl(0 0% 98%);

  --neutral: hsl(258 7% 43%);
  --neutral-fg: hsl(0 0% 98%);

  /* Surfaces */
  --surface: hsl(0 0% 100%);
  --surface-raised: hsl(0 0% 100%);
  --surface-subtle: hsl(262 24% 96%);
  --surface-solid: hsl(261 16% 13%);

  /* Text */
  --text: hsl(261 20% 13%);
  --text-muted: hsl(259 9% 42%);
  --text-subtle: hsl(258 7% 57%);

  /* Lines and focus */
  --border: hsl(260 15% 88%);
  --focus: var(--neutral);
  /* Pre-color-mix fallback only — superseded below by a derivation from
     --focus so overriding --neutral/--focus never leaves a stale ring. */
  --focus-ring: hsl(258 7% 43% / 0.3);
  --hover-overlay: hsl(260 20% 10% / 0.04);

  /* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
  --radius: 0.5rem;
  --radius-sm: 0.25rem;
  --radius-lg: 0.75rem;

  /* Shadow color — consumed by --shadow / --shadow-popout in tokens.css */
  --shadow-color: hsl(260 25% 10%);

  /* hgroup internal gap */
  --hgroup-gap: 0.25em;
}

/* Theme-derived aliases — re-declared on every theme boundary (see tokens.css
   for the custom-property resolution rationale): islands recompute these from
   their own tokens instead of inheriting :root's resolved values. */
:root,
[data-theme] {
  --heading: var(--text);
  --selection-bg: var(--primary);
  --selection-fg: var(--primary-fg);
  --focus-ring-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring);
}

/* Soft foreground hooks — the soft ink is the intent rebated toward the local
   text so it keeps 4.5:1 against the soft hover fill (the resting fill carries
   the extra margin; variants.css soft recipe). Deliberately scoped to the
   default theme: a custom property bakes its var() references where it is
   declared, so these resolve against root/light/dark tokens on every boundary.
   Presets and custom islands keep their --soft-fg-mix / raw-intent ink unless
   they declare hooks themselves — :where() keeps this reset from outranking a
   theme's own declaration (e.g. brutalist's 45% soft-fg-mix). */
:where([data-theme]:not([data-theme="light"]):not([data-theme="dark"])) {
  --primary-soft-fg: initial;
  --secondary-soft-fg: initial;
  --success-soft-fg: initial;
  --warning-soft-fg: initial;
  --danger-soft-fg: initial;
  --neutral-soft-fg: initial;
}

:root,
[data-theme="light"],
[data-theme="dark"] {
  --secondary-soft-fg: color-mix(in oklab, var(--text) 10%, var(--secondary) 90%);
  --success-soft-fg: color-mix(in oklab, var(--text) 15%, var(--success) 85%);
  --warning-soft-fg: color-mix(in oklab, var(--text) 25%, var(--warning) 75%);
  --danger-soft-fg: color-mix(in oklab, var(--text) 15%, var(--danger) 85%);
  --neutral-soft-fg: color-mix(in oklab, var(--text) 20%, var(--neutral) 80%);
}

/* Native platform controls (checkbox, radio, range, progress) that are not
   restyled by a component class still follow the theme accent. Declared on
   every island: accent-color inherits as a computed value, so islands must
   recompute it from their own --primary (same resolution rule as above).
   Unsupported browsers ignore it; forced-colors keeps browser control. */
:root,
[data-theme] {
  accent-color: var(--primary);
}

/* Tone subsystem — fixed-backdrop foreground for components placed over media
   or other surfaces outside the theme token stack. Use data-theme islands for
   full dark/light subtree inversion. */
[data-tone="dark"] {
  --tone-fg: white;
}

[data-tone="light"] {
  --tone-fg: var(--text);
}

/* Focus ring derivation — color-mix tier. Runs whether or not light-dark()
   is also supported, so it covers the color-mix-without-light-dark browser
   tier on its own; the light-dark block below refines it further with the
   light/dark alpha split once both features are available. Declared on every
   [data-theme] island (not just the built-in light/dark pair) so custom
   islands recolor the ring for free; a preset that re-declares --focus-ring
   later in source order intentionally pins its own ring instead. */
@supports (color: color-mix(in oklch, red, white)) {
  :root,
  [data-theme] {
    --focus-ring: color-mix(in oklch, var(--focus) 30%, transparent);
  }
}

/* Auto dark mode via light-dark(). Resolves based on the element's color-scheme:
    :root (light dark) follows the OS, [data-theme="light"] forces light,
    [data-theme="dark"] forces dark by overriding color-scheme.
    Dark pastels sit a little lower and more chromatic (L 70-76, sat 38-45)
    than usual so the palette stays warm rather than washed gray. */
@supports (color: light-dark(red, blue)) {
  :root,
  [data-theme="light"] {
    --primary: light-dark(hsl(268 32% 33%), hsl(268 42% 76%));
    --primary-fg: light-dark(hsl(0 0% 98%), hsl(268 30% 12%));

    --secondary: light-dark(hsl(16 38% 38%), hsl(18 45% 72%));
    --secondary-fg: light-dark(hsl(0 0% 98%), hsl(18 32% 11%));

    --success: light-dark(hsl(150 40% 31%), hsl(150 38% 62%));
    --success-fg: light-dark(hsl(0 0% 98%), hsl(150 35% 8%));

    --warning: light-dark(hsl(36 68% 34%), hsl(40 62% 64%));
    --warning-fg: light-dark(hsl(0 0% 98%), hsl(38 35% 10%));

    --danger: light-dark(hsl(5 58% 43%), hsl(4 60% 70%));
    --danger-fg: light-dark(hsl(0 0% 98%), hsl(4 35% 10%));

    --neutral: light-dark(hsl(258 7% 43%), hsl(258 10% 72%));
    --neutral-fg: light-dark(hsl(0 0% 98%), hsl(260 18% 10%));

    --surface: light-dark(hsl(0 0% 100%), hsl(263 17% 9%));
    --surface-raised: light-dark(hsl(0 0% 100%), hsl(263 15% 12%));
    --surface-subtle: light-dark(hsl(262 24% 96%), hsl(263 13% 16%));
    --surface-solid: light-dark(hsl(261 16% 13%), hsl(0 0% 96%));

    --text: light-dark(hsl(261 20% 13%), hsl(260 12% 95%));
    --text-muted: light-dark(hsl(259 9% 42%), hsl(259 9% 67%));
    --text-subtle: light-dark(hsl(258 7% 57%), hsl(258 7% 54%));

    --border: light-dark(hsl(260 15% 88%), hsl(262 12% 24%));
    --focus-ring: light-dark(hsl(258 7% 43% / 0.3), hsl(258 10% 72% / 0.28));
    --hover-overlay: light-dark(hsl(260 20% 10% / 0.04), hsl(0 0% 100% / 0.06));
    --shadow-color: light-dark(hsl(260 25% 10%), hsl(262 40% 2%));
  }
}

@supports (color: color-mix(in oklch, red, white)) and (color: light-dark(red, blue)) {
  :root,
  [data-theme] {
    --focus-ring: light-dark(
      color-mix(in oklch, var(--focus) 30%, transparent),
      color-mix(in oklch, var(--focus) 28%, transparent)
    );
  }
}

@media (prefers-contrast: more) {
  :root,
  /* The repeated attribute intentionally outranks single-attribute preset
     palettes, including a preset stylesheet loaded after the core. */
  [data-theme][data-theme] {
    --border-width: 2px;
    --focus-outline-offset: 3px;
    --focus-ring-width: 4px;
    --text-muted: var(--text);
    --text-subtle: var(--text);
  }
}

/*
 * Base element styles for native HTML semantics.
 *
 * Sits between reset (browser defaults) and prose (rich content scope).
 * These styles apply everywhere — cards, dialogs, heroes — not only in prose.
 *
 * Public hooks:
 * --link
 * --hr-space
 */

body {
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-sans);
  line-height: var(--line-height);
}

/* Bare links inherit their ink by default, so nothing changes unless a theme
   opts in by setting --link. Because this is an element selector, any component
   rule that colors its own anchors still wins. */
a {
  color: var(--link, inherit);
  text-underline-offset: 0.18em;
}

a:hover {
  text-decoration-thickness: 0.12em;
}

/* Global heading chrome: color (--heading or inherit), a tight line-height,
   and progressive text-wrap. No font-size or font-weight is set globally —
   components and .prose own the per-context scale. */
:where(h1, h2, h3, h4, h5, h6) {
  color: var(--heading, inherit);
  line-height: var(--line-height-tight);
  text-wrap: balance;
}

/* Compact heading groups. Structural only: display, gap, internal margin reset.
   No font-size, color, or weight is applied here. */
hgroup {
  display: grid;
  gap: var(--hgroup-gap);
}

hgroup > * {
  margin-block: 0;
}

/* Section break.

   margin-inline: 0 overrides the UA's `margin: 0.5em auto`. In block flow the
   auto margins resolve to zero and nothing changes, but as a flex or grid item
   an inline auto margin replaces the stretch — an <hr> in a .stack computed to
   width 0 and vanished. .stack deliberately leaves the inline axis free, so
   the reset belongs here.

   --hr-space tunes the block rhythm on the instance: a rule closing a page
   section and a rule separating two rows in a panel are the same element at
   very different scales. It applies to an hr in document flow; inside .stack
   or .prose the parent owns the spacing between siblings and wins. */
hr {
  border: 0;
  border-block-start: var(--border-width) solid var(--border);
  margin-block: var(--hr-space, var(--space-60));
  margin-inline: 0;
}

code,
kbd,
samp,
pre {
  font-family: var(--font-mono);
}

/* Overflow containment only. One unbreakable line — a URL, a minified token, a
   wide ASCII table — otherwise widens the whole page, and an author cannot see
   it coming from the markup. Background, padding and radius stay out: those
   belong to .prose or to an application code-block component. */
pre {
  overflow-x: auto;
}

small {
  font-size: 0.875em;
}

::selection {
  background: var(--selection-bg, var(--primary));
  color: var(--selection-fg, var(--primary-fg));
}

/*
 * Intent classes do not style elements directly.
 * They only expose --intent, --intent-fg, and the optional per-intent soft
 * foreground --intent-soft-fg, relayed from the per-role --*-soft-fg hooks.
 * The default theme declares those hooks for its soft ink (see theme.css);
 * a theme without them lets the relay resolve to an invalid value, so the soft
 * recipe falls back to its --soft-fg-mix derivation and a theme opts in one
 * role at a time (docs/pages/foundations/tokens.md). Components and variants
 * decide how to consume them. Each component that consumes --intent,
 * --intent-soft-fg or --ui-* opens a local intent boundary in a :where() block
 * that resets exactly the tokens it consumes to initial. Parent intents do not
 * leak into nested components. Local intent classes (.primary, .danger, etc.)
 * still apply because they have higher specificity than :where().
 */
.primary {
  --intent: var(--primary);
  --intent-fg: var(--primary-fg);
  --intent-soft-fg: var(--primary-soft-fg);
}
.secondary {
  --intent: var(--secondary);
  --intent-fg: var(--secondary-fg);
  --intent-soft-fg: var(--secondary-soft-fg);
}
.success {
  --intent: var(--success);
  --intent-fg: var(--success-fg);
  --intent-soft-fg: var(--success-soft-fg);
}
.warning {
  --intent: var(--warning);
  --intent-fg: var(--warning-fg);
  --intent-soft-fg: var(--warning-soft-fg);
}
.danger {
  --intent: var(--danger);
  --intent-fg: var(--danger-fg);
  --intent-soft-fg: var(--danger-soft-fg);
}
.neutral {
  --intent: var(--neutral);
  --intent-fg: var(--neutral-fg);
  --intent-soft-fg: var(--neutral-soft-fg);
}

/* Paint an inherited or local intent on a simple foreground element without
   turning the intent classes into general-purpose color utilities. */
.intent-color {
  color: var(--intent);
}

/*
 * Cross-cutting variant token mapping.
 *
 * Variant scope: .solid/.soft/.outline/.surface set --ui-* for components that
 * opt into shared variants. .inverted is a surface modifier, not a variant: it paints
 * the element directly (background/color/border-color) so it works on any bare
 * block, and remaps the shared --ui-* tokens so participating components
 * (.card, .navbar, .alert, .badge) invert through their own rules. Loaded
 * before components, so a component with its own explicit surface (e.g.
 * .accordion) naturally keeps that surface — .inverted never forces a
 * non-participant.
 * Component-specific defaults (.btn solid-by-default, .badge
 * soft-by-default, .alert soft-by-default) live with their components:
 *   components/button.css — .btn default solid + .btn.ghost + .btn.link
 *   components/badge.css   — .badge default soft behind explicit --ui-*
 *   components/alert.css   — .alert default soft behind explicit --ui-*
 *
 * .compact/.spacious set inherited density tokens: spacing rhythm and geometry
 * only. Density never changes typography or icon size. Controls consume
 * --control-size; non-control components may consume --density-* when the
 * context should affect their local rhythm.
 *
 * .sm/.lg are local size roles, not shared measurements. Shared control
 * participants below map both typography and geometry; components with
 * bespoke geometry (badge, spinner, avatar, rating, key, prose) own those
 * mappings locally. Components without an explicit mapping do not participate.
 *
 * Intent list source of truth: intents.css. Components consume --intent* tokens
 * instead of re-listing intent classes.
 */
.solid {
  --ui-bg: var(--intent, var(--neutral));
  --ui-fg: var(--intent-fg, var(--neutral-fg));
  --ui-border: transparent;
}

.soft {
  --ui-bg: var(--surface-subtle);
  --ui-border: var(--border);
  --ui-fg: var(--intent, var(--text));
}

.outline {
  --ui-bg: transparent;
  --ui-fg: var(--intent, currentColor);
  --ui-border: var(--intent, currentColor);
}

/* Neutral chrome, intent only in the ink: the page surface with the theme
   border. .soft is the intent-tinted treatment (its baseline fallback uses
   --surface-subtle) and .outline has no fill at all, so neither can express
   it. Deliberately literal — no color-mix enhancement — so this stays the one
   variant that reads exactly as the theme's own surface. */
.surface {
  --ui-bg: var(--surface);
  --ui-fg: var(--intent, var(--text));
  --ui-border: var(--border);
}

/* Inverse surface treatment. Paints the element directly (so it works on any
   bare block) and remaps the shared --ui-* tokens so opt-in components
   (.card, .navbar) invert through their own rules. Because this loads before
   component base surfaces, components with their own explicit surface
   (e.g. .accordion) keep it — participation is opt-in, never forced. --heading
   is set so global headings (var(--heading, inherit)) do not inherit an outside
   heading color onto the dark surface; surface-owning descendants derive
   their contextual aliases from their own local surface. */
.inverted {
  --ui-bg: var(--surface-solid);
  --ui-fg: var(--surface);
  --ui-border: transparent;
  --ui-hover-bg: var(--surface-solid);

  --heading: var(--surface);
  --busy-overlay-bg: var(--surface-solid);

  background: var(--ui-bg);
  color: var(--ui-fg);
  border-color: var(--ui-border);
}

.compact {
  --gap: var(--space-20);
  --density-space: var(--space-20);
  --control-size: var(--control-size-sm);
}

.spacious {
  --gap: var(--space-50);
  --density-space: var(--space-50);
  --control-size: var(--control-size-lg);
}

/* Shared control scale. Wrappers are listed when their descendants form one
   component (field, input-icon, join, pagination, tabs, flyout). A local size
   declaration beats inherited density because it is declared on the sized
   component itself. */
:where(
  .btn,
  .input,
  .textarea,
  .select,
  .color,
  .file,
  .field,
  .choice,
  .input-icon,
  .otp,
  .join,
  .pagination,
  .tabs,
  .tab,
  .flyout
).sm {
  --control-size: var(--control-size-sm);
  --control-font-size: var(--font-size-sm);
  --control-pad-x: var(--font-size-sm);
}

:where(
  .btn,
  .input,
  .textarea,
  .select,
  .color,
  .file,
  .field,
  .choice,
  .input-icon,
  .otp,
  .join,
  .pagination,
  .tabs,
  .tab,
  .flyout
).lg {
  --control-size: var(--control-size-lg);
  --control-font-size: var(--font-size-lg);
  --control-pad-x: var(--font-size-lg);
}

/* Modern color enhancement. Baseline variant values above remain usable
   everywhere. Interactive states live in the components that consume them. */
@supports (color: color-mix(in oklch, red, white)) {
  .soft {
    /* @sync soft-recipe */
    --ui-bg: color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
    --ui-border: color-mix(
      in oklab,
      var(--surface) var(--soft-border-mix),
      var(--intent, var(--neutral))
    );
    --ui-fg: var(
      --intent-soft-fg,
      color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
    );
    /* @sync soft-recipe */
  }

  .inverted {
    --ui-hover-bg: color-mix(in oklch, var(--surface-solid) 88%, black);
  }
}

/* Interactive hover recipes live in the components that consume them:
     components/button.css and components/badge.css. */

/* Focus baseline — a visible outline is the guaranteed focus affordance. The
   :focus outline covers legacy browsers; modern browsers hide it for
   pointer/mouse focus and interactive components layer their ring on top.

   Invariant: an interactive component must never rely on box-shadow alone
   for its keyboard focus indicator. Components that remove the native
   outline on :focus-visible replace it with a transparent outline — invisible
   normally, forced to the system focus color under forced-colors. */
:focus {
  outline: var(--focus-outline);
  outline-offset: var(--focus-outline-offset);
}

@supports selector(:focus-visible) {
  :focus:not(:focus-visible) {
    outline: none;
  }
}

@media print {
  :root,
  body {
    /* Save some ink in dark mode */
    color-scheme: light;
    background: #fff;
    color: #000;
  }

  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    break-after: avoid;
  }
}

/* Typography — prose scope and typographic treatments. */
/*
 * Prose is the opt-in rich text scope for authored content.
 *
 * Global element styles stay mild so app UI is not shaped accidentally. Page
 * layout, forms, cards, navigation, and app tables remain outside this scope.
 *
 * Public hooks:
 * --prose-size
 * --prose-line-height
 * --prose-measure
 * --prose-flow
 * --prose-heading-line-height
 * --prose-h1
 * --prose-h2
 * --prose-h3
 * --prose-h4
 * --link
 */

.prose {
  /* Prose scale */
  --prose-size: 1rem;
  --prose-line-height: 1.7;
  --prose-measure: 70ch;
  --prose-flow: 1em;
  --prose-heading-line-height: 1.2;
  --prose-h1: 2rem;
  --prose-h2: 1.5rem;
  --prose-h3: 1.25rem;
  --prose-h4: 1.125rem;

  max-inline-size: var(--prose-measure);
  font-size: var(--prose-size);
  line-height: var(--prose-line-height);
  overflow-wrap: break-word;
}

/* Vertical rhythm belongs to the direct block that participates in the
   document, so it must not be injected into the internals of a nested
   component (e.g. a .table-wrap around a .table would trap the margin). */
.prose > * {
  margin-block-end: 0;
}

.prose > * + * {
  margin-block-start: var(--prose-flow);
}

/* Heading hierarchy remains low-specificity and contextual: a surface may
   remap --heading without prose repainting it with the root text color. */
.prose :where(h1, h2, h3, h4, h5, h6) {
  margin-block: 1.5em 0;
  color: var(--heading, var(--text));
  font-weight: var(--font-weight-semibold);
  line-height: var(--prose-heading-line-height);
}

.prose :where(p, li, blockquote) {
  text-wrap: pretty;
}

.prose > :first-child {
  margin-block-start: 0;
}

/* Element recipes are wrapped in :where() so every .prose rule is 0-1-0.
   An explicit component/util class can therefore override prose defaults
   regardless of the underlying element — a .btn or .alert link must not be
   reshaped just because it lives inside authored content. */
.prose :where(h1) {
  font-size: var(--prose-h1);
}

.prose :where(h2) {
  font-size: var(--prose-h2);
}

.prose :where(h3) {
  font-size: var(--prose-h3);
}

.prose :where(h4) {
  font-size: var(--prose-h4);
}

/* --link is a theme-level token (core/base.css also reads it for bare links);
   prose falls back to --primary so a theme that never sets it is unaffected. */
.prose :where(a) {
  color: var(--link, var(--primary));
  text-decoration-line: underline;
  text-decoration-thickness: 0.08em;
  text-underline-offset: 0.18em;
}

.prose :where(a:hover, a:focus-visible) {
  text-decoration-thickness: 0.12em;
}

@supports (color: color-mix(in oklch, red, white)) {
  .prose :where(a:hover, a:focus-visible) {
    color: color-mix(in oklab, var(--link, var(--primary)), var(--text) 15%);
  }
}

/* Rich-text descendants are intentional here. Components and application UI
   belong outside the prose boundary. */
.prose :where(ul, ol) {
  padding-inline-start: 1.5em;
}

.prose :where(li + li) {
  margin-block-start: 0.35em;
}

/* Description lists keep document flow: a valid <dl> may pair several <dt>
   with one <dd> or the reverse, so no grid layout is imposed here. */
.prose :where(dt) {
  font-weight: var(--font-weight-semibold);
}

.prose :where(dd) {
  margin-inline-start: 1.5em;
}

.prose :where(dd + dt) {
  margin-block-start: 0.75em;
}

.prose :where(code) {
  padding: 0.15em 0.3em;
  border-radius: var(--radius-sm);
  background: var(--surface-subtle);
  color: var(--text);
  font-size: 0.875em;
}

/* Visual twin of .key (components/key.css) — keep the two recipes in sync. */
/* @sync key-recipe */
.prose :where(kbd) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-inline-size: 1.75em;
  min-block-size: 1.6em;
  padding-inline: 0.45em;
  border: var(--border-width) solid var(--border);
  border-block-end-width: calc(var(--border-width) * 2);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.875em;
  font-weight: var(--font-weight-medium);
  line-height: 1;
  white-space: nowrap;
  vertical-align: 0.08em;
}
/* @sync key-recipe */

/* Overflow containment is global on `pre` (base.css); only the box is prose's. */
.prose :where(pre) {
  padding: 1em;
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-subtle);
}

.prose :where(pre) :where(code) {
  padding: 0;
  background: transparent;
}

.prose :where(blockquote) {
  margin-inline: 0;
  padding-inline-start: 1em;
  border-inline-start: var(--border-width) solid var(--border);
  color: var(--text-muted);
}

.prose :where(figure) {
  margin-inline: 0;
}

.prose :where(figcaption) {
  margin-block-start: 0.5em;
  color: var(--text-muted);
  font-size: 0.875em;
}

.prose :where(table) {
  inline-size: 100%;
  border-collapse: collapse;
}

.prose :where(th, td) {
  padding: 0.5em;
  border-block-end: var(--border-width) solid var(--border);
  text-align: start;
}

.prose.sm {
  --prose-size: 0.9375rem;
  --prose-h1: 1.75rem;
  --prose-h2: 1.375rem;
  --prose-h3: 1.125rem;
  --prose-h4: 1rem;
}

.prose.lg {
  --prose-size: 1.125rem;
  --prose-measure: 72ch;
  --prose-h1: 2.25rem;
  --prose-h2: 1.625rem;
  --prose-h3: 1.375rem;
  --prose-h4: 1.25rem;
}

/* Lead paragraph: a slightly larger, more relaxed reading-introduction text.
   Non-fluid and always available — use .text-lead from the optional fluid
   module for marketing/display surfaces. */
.lead {
  font-size: 1.125em;
  line-height: var(--line-height-relaxed);
}

/*
 * Overline — editorial small-cap label for section openers.
 *
 * Base form is unstyled chrome that takes its surrounding color. Add .pill for
 * the bordered rounded shape, and combine with an intent class (.primary,
 * .success, etc.) to tint the text, border, and background.
 *
 * Public hooks:
 * --overline-radius
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.overline) {
  --intent: initial;
}

.overline {
  color: var(--text-muted);
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-medium);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.overline.pill {
  --overline-radius: var(--radius-full);

  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  padding-inline: 0.6em;
  padding-block: 0.25em;
  inline-size: fit-content;
  max-inline-size: 100%;
  color: var(--intent, var(--text-muted));
  border: var(--border-width) solid var(--border);
  border-radius: var(--overline-radius);
  background: var(--surface);
}

/* Tinted pill surface where color-mix is available; the flat fallback above
   keeps a readable bordered pill everywhere else. */
@supports (color: color-mix(in oklch, red, white)) {
  .overline.pill {
    border-color: color-mix(in oklab, var(--intent, var(--border)) 35%, var(--border));
    background: color-mix(in oklab, var(--intent, transparent) 6%, var(--surface));
  }
}

/*
 * Optional fluid typography module.
 *
 * Part of the typography family (typography/index.css) and the full bundle; it
 * is not in the minimal core, so a core-only import adds it explicitly when
 * display, title, and lead sizes must scale with the viewport. Typical use
 * cases: marketing pages, landing pages, hero sections, documentation
 * homepages.
 *
 * Do not use this for body text, app UI, or the default prose scale. Fluid
 * sizing is not the foundation of the typography system — it is a small opt-in
 * surface for display text on content-heavy pages.
 *
 * Public API:
 *   --fluid-display, --fluid-title, --fluid-lead  (size tokens, clamp())
 *   .text-display, .text-title, .text-lead        (composed classes)
 *
 * Public hooks:
 * --fluid-display
 * --fluid-title
 * --fluid-lead
 *
 * Values are generated from scripts/rfs.ts over the 360px -> 1440px range.
 */
:root {
  --fluid-display: clamp(2.75rem, calc(2.25rem + 2.222vw), 4.25rem);
  --fluid-title: clamp(2rem, calc(1.75rem + 1.111vw), 2.75rem);
  --fluid-lead: clamp(1.125rem, calc(1.0833rem + 0.185vw), 1.25rem);
}

@supports (inline-size: 100vi) {
  :root {
    --fluid-display: clamp(2.75rem, calc(2.25rem + 2.222vi), 4.25rem);
    --fluid-title: clamp(2rem, calc(1.75rem + 1.111vi), 2.75rem);
    --fluid-lead: clamp(1.125rem, calc(1.0833rem + 0.185vi), 1.25rem);
  }
}

.text-display {
  font-size: var(--fluid-display);
  line-height: var(--line-height-tight);
  text-wrap: balance;
}

.text-title {
  font-size: var(--fluid-title);
  line-height: var(--line-height-tight);
  text-wrap: balance;
}

.text-lead {
  font-size: var(--fluid-lead);
  line-height: var(--line-height-relaxed);
}

/*
 * Layout — composition primitives. Readable cascade order: container-query
 * first (a local opt-in, unrelated to the primitives), then the primitives.
 *
 * Selection:
 * .stack          vertical flow
 * .cluster        wrapping inline group
 * .center         centered content column
 * .frame          fixed-ratio media frame
 * .app-shell      full-height application shell
 * .app-layout     topbar + scrolling main + bottom/side app navigation
 * .media          fixed media + flexible content
 * .switcher       peer regions: all horizontal or all stacked
 * .sidebar-layout main content + secondary aside
 * .grid           responsive collection; item width is the contract
 *  .grid-N         structural density of N; balanced inside a container named
 *                  actual-container (in grid.css)
 * .column-layout  explicit 12-column region composition; the author owns
 *                 narrow-container recomposition
 * .scroller       theme-aware scrollbar density and colour
 * .scroll-snap    snap rails
 * .scroll-target  anchor scroll offset for sticky headers
 * .topbar         sticky app-shell top bar
 * .measure        readable content measure cap
 *
 * Prefer these compositions over viewport breakpoint utilities.
 */
/* Convenience helper for size-aware components: establishes an inline-size
   query container named actual-container. The name is the contract, not this
   class — an author can declare `container: actual-container / inline-size` on
   their own region and get the same result.

   actual-container is shared. Granting it to a region may enable size-aware
   enhancements for any Actual component inside that region, not only the one
   that motivated the container: .grid-N reads it for balanced subdivision,
   .steps for its label collapse, and future components will read the same
   context. That is the scope of the API, not a caveat.

   An anonymous query container is not part of it. A region that declares
   `container-type: inline-size` for the application's own purposes carries no
   actual-container name, so no framework component changes behaviour there.
   Opting into the protocol is always explicit. Nothing here is a prerequisite
   either — every primitive is responsive without it; this adds precision.

   Place it where the width you want measured lives: usually a wrapper for a
   grid, whose queried children sit inside it, and the component itself for
   .steps, whose own row is the width in question.

   The rule for where it is safe is the containment rule, not a list of
   primitives. Query containment removes the element's intrinsic inline-size
   contribution, so:

     safe    the element's width is imposed by its context
             (.steps sets inline-size: 100%, so it survives even as a
             .cluster child — it wraps and fills)

     unsafe  the element's width depends on its contents' intrinsic
             contribution (a bare .grid child of .cluster measures 0 once
             contained, as does a float, inline-block, table, grid auto track,
             max-content parent, or an abspos box sized by its contents)

   So .cluster is neither safe nor unsafe; what matters is where the child's
   width comes from. */
.container-query {
  container: actual-container / inline-size;
}

/* Vertical flow. .stack owns the block axis only: block margins reset, inline
   margins stay free (self-centering children via margin-inline: auto keep
   working).
   Child contract: direct children become flex items; margins reset, spacing
   comes from --gap.

   --gap is inherited, so a container that composes other primitives sets its
   own rhythm on the `gap` property, not on the token: setting --gap on a
   region re-spaces every nested stack, cluster and grid at any depth. Setting
   the token stays correct on the element that consumes it, and
   .compact/.spacious set it deliberately so a density context reaches nested layouts. See
   docs/pages/layout/stack.md. */
.stack {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
}

.stack > * {
  margin-block: 0;
}

/*
 * Inline group that wraps when space runs out.
 *
 * Public hooks:
 * --cluster-align
 * --cluster-justify
 * --cluster-wrap
 *
 * Child contract: direct-child margins are reset, spacing comes from --gap.
 */
.cluster {
  display: flex;
  flex-direction: row;
  flex-wrap: var(--cluster-wrap, wrap);
  align-items: var(--cluster-align, center);
  justify-content: var(--cluster-justify, flex-start);
  gap: var(--gap);
}

.cluster > * {
  margin: 0;
}

/*
 * Centered content column with a maximum measure.
 *
 * Public hooks:
 * --center-size
 * --center-pad
 */
.center {
  box-sizing: content-box;
  inline-size: calc(100% - var(--center-pad, 1rem) * 2);
  max-inline-size: var(--center-size, 72rem);
  margin-inline: auto;
  padding-inline: var(--center-pad, 1rem);
}

/*
 * Media frame with a fixed ratio.
 *
 * Public hooks:
 * --frame-ratio
 *
 * Child contract: direct media children (img/video/iframe/canvas/svg) are
 * sized to fill the frame.
 */
.frame {
  overflow: hidden;
  aspect-ratio: var(--frame-ratio, 16 / 9);
}

.frame > :where(img, video, iframe, canvas, svg) {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
}

/* Full-height single-column document shell. */
.app-shell {
  min-block-size: var(--viewport-block);
  display: flex;
  flex-direction: column;
}

/*
 * Specialized application shell recipe: one exact DOM structure uses a
 * bottom navigation bar when narrow and a labelled side navigation when space
 * permits. The main region owns scrolling, keeping both pieces of application
 * chrome available without fixed offsets. This is not a generic page wrapper
 * or a public-site layout; all three direct regions are part of the contract.
 *
 * Public hook:
 * --app-nav-side-size
 */
.app-layout {
  --app-nav-side-size: 12rem;

  display: grid;
  grid-template:
    "topbar" auto
    "main" minmax(0, 1fr)
    "nav" auto
    / minmax(0, 1fr);
  block-size: var(--viewport-block);
  min-inline-size: 0;
  overflow: hidden;
}

.app-layout > .topbar {
  grid-area: topbar;
  padding-block-start: calc(var(--space-30) + env(safe-area-inset-top));
}

.app-layout > .app-main {
  grid-area: main;
  min-inline-size: 0;
  min-block-size: 0;
  overflow: auto;
}

.app-layout > .app-nav {
  grid-area: nav;
}

/* The main grid area keeps the FAB clear of bottom navigation without making
   either component copy the other's geometry. */
.app-layout > .fab {
  position: relative;
  grid-area: main;
  place-self: end;
  inset: auto;
  margin: var(--fab-offset);
}

@media (min-width: 48rem) {
  .app-layout {
    grid-template:
      "nav topbar" auto
      "nav main" minmax(0, 1fr)
      / var(--app-nav-side-size) minmax(0, 1fr);
  }

  .app-layout > .app-nav {
    position: sticky;
    inset-block: 0;
    grid-auto-flow: row;
    grid-auto-rows: minmax(var(--control-size-lg), auto);
    grid-auto-columns: auto;
    align-content: start;
    gap: var(--space-20);
    min-block-size: 0;
    padding-block: max(var(--space-20), env(safe-area-inset-top))
      max(var(--space-20), env(safe-area-inset-bottom));
    padding-inline: var(--space-20);
    border-block-start: 0;
    border-inline-end: var(--border-width) solid var(--ui-border, var(--border));
  }

  .app-layout > .app-nav > a {
    grid-template-rows: auto;
    grid-template-columns: 1.5rem minmax(0, 1fr);
    place-content: center start;
    align-items: center;
    justify-items: start;
    gap: var(--space-30);
    padding-inline: var(--space-30);
    text-align: start;
  }

  /* In the side layout the FAB owns the viewport's bottom safe-area inset. */
  .app-layout > .fab {
    margin-block-end: max(var(--fab-offset), env(safe-area-inset-bottom));
  }
}

@media print {
  .app-layout {
    display: block;
    block-size: auto;
    overflow: visible;
  }

  .app-layout > .app-main {
    overflow: visible;
  }
}

/* Media object: fixed-width leading element (avatar, icon, image) + flexible
   trailing content. Replaces the repeated "auto minmax(0, 1fr)" pattern in
   author cards, comments, meta rows, and similar compositions.
   Child contract: exactly two direct children (leading media + content). */
.media {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--gap);
  align-items: start;
}

/*
 * Peer regions that switch as a group.
 *
 * All children sit side by side when they all fit; otherwise every child
 * stacks. Unlike .grid, this recipe cannot create a partial row such as two
 * items plus one orphan.
 *
 * Public hooks:
 * --switcher-threshold
 *
 * Child contract: every direct child is a peer; the flex-basis applies to all.
 */
.switcher {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--gap);
}

.switcher > * {
  flex-basis: calc((var(--switcher-threshold, 40rem) - 100%) * 999);
  flex-grow: 1;
  min-inline-size: 0;
}

/*
 * Main content + secondary region.
 *
 * The children wrap according to the space allocated to this layout, without
 * an ancestor opt-in or viewport threshold. The large growth ratio lets the
 * main region reclaim the full row when the aside wraps instead of remaining
 * at its minimum viable width.
 *
 * Public hooks:
 * --sidebar-content-min
 * --sidebar-layout-size
 *
 * Child contract: the main region is first and the aside second by default
 * (aside on the inline end). .reverse expects the aside first in the DOM, so
 * visual, reading and keyboard order all agree — the role rules swap so the
 * aside still takes its preferred width and the main still reclaims the row.
 */
.sidebar-layout {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--gap);
  align-items: flex-start;
}

/* Main region — flexible, reclaims the full row when the aside wraps. */
.sidebar-layout > :first-child {
  flex-basis: 0;
  flex-grow: 999;
  min-inline-size: min(100%, var(--sidebar-content-min, 30rem));
}

/* Aside region — preferred width, never forces content min-width. */
.sidebar-layout > :last-child {
  flex-basis: var(--sidebar-layout-size, 18rem);
  flex-grow: 1;
  min-inline-size: 0;
}

/* .reverse: the aside is first in the DOM and the main last, so each role
   rule above would hit the wrong element. Swap the sizing per child; the
   higher-specificity reverse rules outrank the positional defaults. */
.sidebar-layout.reverse > :last-child {
  flex-basis: 0;
  flex-grow: 999;
  min-inline-size: min(100%, var(--sidebar-content-min, 30rem));
}

.sidebar-layout.reverse > :first-child {
  flex-basis: var(--sidebar-layout-size, 18rem);
  flex-grow: 1;
  min-inline-size: 0;
}

/*
 * Responsive equal-width grids.
 *
 * .grid is space-driven: it fits as many tracks as --grid-min allows.
 * .grid-N is count-driven: it declares a structural density of N and collapses
 * through natural divisors of N.
 *
 * Public hooks:
 * --grid-min
 * --grid-columns
 *
 * Internal:
 * --grid-count
 *
 * Child contract: each direct child is a grid item placed in the tracks the
 * recipe generates.
 *
 * Choosing between these and the other layout primitives:
 * docs/pages/layout/grid.md. Why divisors, why a *named* container, and how
 * the thresholds were arrived at: docs/design-notes/grid.md.
 */

/* --grid-columns replaces the intrinsic auto-fit template: once set, the grid
   no longer collapses on its own and the author owns narrow-container
   collapse. */
.grid {
  display: grid;
  gap: var(--gap);
  grid-template-columns: var(
      --grid-columns,
      repeat(auto-fit, minmax(min(100%, var(--grid-min, 16rem)), 1fr))
    );
}

/* Baseline: auto-fill bounded at N, so a preset is responsive and
   overflow-safe with no wrapper at all. auto-fill rather than auto-fit keeps
   item width stable when a partial final page holds fewer items than the
   grid's capacity.

   --grid-min is deliberately NOT read here: @container thresholds cannot
   resolve a custom property, so a hook honored in the baseline would fall
   silent the moment a query container is added. */
:where(.grid-2, .grid-3, .grid-4, .grid-6) {
  display: grid;
  gap: var(--gap);
  grid-template-columns: repeat(
      auto-fill,
      minmax(
        min(
          100%,
          max(16rem, calc((100% - (var(--grid-count) - 1) * var(--gap)) / var(--grid-count)))
        ),
        1fr
      )
    );
}

.grid-2 {
  --grid-count: 2;
}
.grid-3 {
  --grid-count: 3;
}
.grid-4 {
  --grid-count: 4;
}
.grid-6 {
  --grid-count: 6;
}

/* Grid items keep an automatic min-content floor that can overflow a
   minmax(0, 1fr) track. Layout primitives remove it so their overflow-safe
   track sizing holds; .min-inline-size-0 is for the ones that do not. */
:where(.grid, .grid-2, .grid-3, .grid-4, .grid-6) > * {
  min-inline-size: 0;
}

/* Balanced subdivision, keyed on an ancestor container *named*
 * actual-container — never an anonymous one, so a region that sets
 * container-type: inline-size for an unrelated reason cannot change a grid.
 *
 *   .grid-2   2 -> 1
 *   .grid-3   3 -> 1
 *   .grid-4   4 -> 2 -> 1
 *   .grid-6   6 -> 3 -> 2 -> 1
 *
 * Every state is a divisor of N. Do not complete these chains with the
 * missing integers and do not replace them with an unbounded auto-fit: both
 * bring back uneven splits such as 5 + 1.
 *
 * The container must be an ANCESTOR. An element cannot query its own size
 * without inline-size containment, which destroys its intrinsic inline
 * contribution and collapses it inside .cluster, .topbar, floats and any
 * shrink-to-fit box.
 *
 * With no such ancestor none of these resolve and the baseline above stays.
 * min-width: 0 is the lowest step: it pins the grid to one column before any
 * larger threshold fires, which a bare grid would otherwise show as a 2 + 1
 * orphan between 28 and 48rem.
 *
 * Ascending min-width only: the last matching step wins by cascade order.
 */
@container actual-container (min-width: 0px) {
  :where(.grid-2, .grid-3, .grid-4, .grid-6) {
    grid-template-columns: 1fr;
  }
}

@container actual-container (min-width: 28rem) {
  :where(.grid-2, .grid-4, .grid-6) {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@container actual-container (min-width: 48rem) {
  :where(.grid-3, .grid-6) {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

@container actual-container (min-width: 64rem) {
  :where(.grid-4) {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }

  :where(.grid-6) {
    grid-template-columns: repeat(6, minmax(0, 1fr));
  }
}

/*
 * Explicit 12-column composition canvas.
 *
 * Neither space-driven like .grid nor count-driven like .grid-N: this is a
 * coordinate system. The author places regions with .column-span-N /
 * .column-start-N and owns narrow-container recomposition. There is
 * deliberately no automatic collapse — no threshold, no hidden
 * .container-query behavior, no breakpoint variants. A single framework
 * threshold cannot be right for arbitrary per-region compositions, and a
 * hidden default would have to be undone rather than extended.
 *
 * See docs/design-notes/column-layout.md.
 *
 * Selection — reach for this only when placement is part of the design:
 *   independent repeated items      -> .grid
 *   a known peer density            -> .grid-N
 *   peers switching together        -> .switcher
 *   content + secondary region      -> .sidebar-layout
 *   media + flexible content        -> .media
 *   exact custom track templates    -> --grid-columns on .grid
 *
 * Child contract: each direct child is placed on the canvas via its own
 * .column-span-N / .column-start-N.
 *
 * Unlike .grid-N, the track template does not read --gap, so the gap
 * utilities are exact here at every width.
 */
.column-layout {
  display: grid;
  gap: var(--gap);
  grid-template-columns: repeat(12, minmax(0, 1fr));
}

/* Three zero-specificity tiers, applied in this order and only ever separated
 * by source order. ORDER IS LOAD-BEARING:
 *
 *   1. the child reset       auto-placed, spanning the whole canvas
 *   2. .column-start-*       a definite start line, running to the canvas end
 *   3. .column-span-*        the end only, so it refines either of the above
 *
 * Moving tier 3 above tier 2 makes every start+span pair run to column 12;
 * moving either above tier 1 makes every placement full-span. Both failures
 * are silent. tests/css-audit.test.js locks the order and
 * tests/browser/column-layout.test.js locks the resulting geometry.
 *
 * Zero specificity is the point: an application's own container-query rules
 * override placement additively, with a single-class selector and without
 * having to undo framework specificity first.
 */

/* Tier 1. A child with no placement class stays a readable full-width region
 * instead of collapsing to one twelfth. Expressed as a span, not as
 * `1 / -1`: a definite start line would give every child a definite column
 * position and disable auto-placement, so `.column-span-8` next to
 * `.column-span-4` would stack instead of composing 8 + 4.
 *
 * min-inline-size: 0 keeps the item box inside its track so the canvas never
 * grows past its container. It does not stop content from painting outside a
 * narrow track — that distinction is the primitive's contract, not a bug. */
:where(.column-layout) > * {
  grid-column-end: span 12;
  min-inline-size: 0;
}

/* Tier 2. A definite start line, running to the end of the canvas unless a
 * .column-span-* refines the end. `column-start-9` alone is therefore the
 * last four columns rather than an implicit 21-line overflow. */
:where(.column-start-1) {
  grid-column: 1 / -1;
}
:where(.column-start-2) {
  grid-column: 2 / -1;
}
:where(.column-start-3) {
  grid-column: 3 / -1;
}
:where(.column-start-4) {
  grid-column: 4 / -1;
}
:where(.column-start-5) {
  grid-column: 5 / -1;
}
:where(.column-start-6) {
  grid-column: 6 / -1;
}
:where(.column-start-7) {
  grid-column: 7 / -1;
}
:where(.column-start-8) {
  grid-column: 8 / -1;
}
:where(.column-start-9) {
  grid-column: 9 / -1;
}
:where(.column-start-10) {
  grid-column: 10 / -1;
}
:where(.column-start-11) {
  grid-column: 11 / -1;
}
:where(.column-start-12) {
  grid-column: 12 / -1;
}

/* Tier 3. The end only. Combined with a start, keep the placement inside the
 * canvas: S + N - 1 <= 12. Exceeding it adds an auto-sized implicit column,
 * which reshapes every 1fr track — the whole canvas loses its alignment, not
 * just the offending region. */
:where(.column-span-1) {
  grid-column-end: span 1;
}
:where(.column-span-2) {
  grid-column-end: span 2;
}
:where(.column-span-3) {
  grid-column-end: span 3;
}
:where(.column-span-4) {
  grid-column-end: span 4;
}
:where(.column-span-5) {
  grid-column-end: span 5;
}
:where(.column-span-6) {
  grid-column-end: span 6;
}
:where(.column-span-7) {
  grid-column-end: span 7;
}
:where(.column-span-8) {
  grid-column-end: span 8;
}
:where(.column-span-9) {
  grid-column-end: span 9;
}
:where(.column-span-10) {
  grid-column-end: span 10;
}
:where(.column-span-11) {
  grid-column-end: span 11;
}
:where(.column-span-12) {
  grid-column-end: span 12;
}

/*
 * Scroller — optional scrollbar treatment for overflow regions.
 *
 * Apply to any scroll container (.overflow-auto, sidebars, long tables). Only
 * the standard properties are used: the engine keeps drawing the scrollbar,
 * this hands it the theme's density and colour. No ::-webkit-scrollbar chrome —
 * rebuilding a thumb by hand means re-implementing the hover, the corner and
 * the light/dark adaptation that color-scheme already gives for free, and the
 * two never match anyway.
 *
 * Above every tier: scrollbar-color lands in Chromium 121 and Safari 18.2, so
 * even a Recommended engine may lack it. Engines that ignore both declarations
 * draw their native scrollbar — the intended fallback, not a broken state.
 *
 * Styles the scrollbar only; it does not create the overflow. Pair it with
 * .overflow-auto or a component that already scrolls.
 *
 * Public hooks:
 * --scroller-track
 * --scroller-thumb
 */
.scroller {
  --scroller-track: transparent;
  --scroller-thumb: var(--border);

  scrollbar-color: var(--scroller-thumb) var(--scroller-track);
  scrollbar-width: thin;
}

/* Opt-in gutter: reserves space so the layout does not shift when
   the scrollbar appears. Use alongside a known-overflow container. */
.scroller.stable-gutter {
  scrollbar-gutter: stable;
}

@supports (color: color-mix(in oklch, red, white)) {
  .scroller {
    --scroller-thumb: color-mix(in oklch, var(--text-muted), transparent 55%);
  }
}

/*
 * Scroll snap — optional horizontal rails for niche card strips and simple
 * touch-friendly overflow areas.
 *
 * This is not a full carousel. For previous/next controls, pagination,
 * active state, drag behavior, looping, or robust desktop mouse interaction,
 * use a dedicated carousel library.
 *
 * Public hooks:
 * --scroll-snap-gap
 * --scroll-snap-align
 * --scroll-snap-item-size
 * --scroll-snap-padding
 */
.scroll-snap {
  --scroll-snap-gap: var(--gap);
  --scroll-snap-align: start;
  --scroll-snap-item-size: auto;

  display: flex;
  flex-direction: row;
  gap: var(--scroll-snap-gap);
  overflow-x: auto;
}

.scroll-snap > * {
  flex: 0 0 var(--scroll-snap-item-size);
}

@supports (scroll-snap-type: x mandatory) {
  .scroll-snap {
    overscroll-behavior-inline: contain;
    scroll-padding-inline: var(--scroll-snap-padding, var(--scroll-snap-gap));
    /* x then inline: an engine that lacks the logical keyword keeps x.
       proximity is the default, so a short rail cannot trap the pointer. */
    scroll-snap-type: x proximity;
    scroll-snap-type: inline proximity;
  }

  /* Strict snapping is opt-in; proximity keeps a rail from fighting the
     pointer on a short strip. */
  .scroll-snap[data-snap="mandatory"] {
    scroll-snap-type: x mandatory;
    scroll-snap-type: inline mandatory;
  }

  .scroll-snap > * {
    scroll-snap-align: var(--scroll-snap-align);
  }

  .scroll-snap[data-snap-align="center"] {
    --scroll-snap-align: center;
  }
}

.scroll-snap[data-scrollbar="hidden"] {
  scrollbar-width: none;
}

.scroll-snap[data-scrollbar="hidden"]::-webkit-scrollbar {
  display: none;
}

/* Anchor scroll offset. A #hash link or scrollIntoView() places the target
 * flush at the scroll container's start edge, which hides it behind a sticky
 * header (.topbar) or underlaps it; scroll-margin reserves space on the
 * target itself so the browser stops early.
 *
 * Apply to the elements that are link targets: <section id="...">, headings,
 * etc. The scrollspy enhancer only reads ids — this offset is what actually
 * shapes where the page lands, and the two are independent.
 *
 * Public hooks:
 * --scroll-target-offset
 */
.scroll-target {
  scroll-margin-block-start: var(--scroll-target-offset, var(--space-60));
}

/*
 * Sticky, frosted top bar shell for app/dashboard shells (persistent
 * sidebar + topbar layouts). Owns positioning, stacking, and the frosted
 * background only — row content (search, breadcrumb, actions) stays local
 * to the page.
 *
 * Not for marketing/editorial site headers — those are one-off visual
 * identity per page; see docs/pages/patterns/overview.md's Header Navigation
 * section. This is for the structural app-shell topbar, which recurs unchanged
 * across app UIs regardless of brand.
 */
.topbar {
  position: sticky;
  inset-block-start: 0;
  z-index: var(--z-sticky);
  isolation: isolate;
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--gap);
  padding-block: var(--space-30);
  padding-inline: var(--space-40);
  border-block-end: var(--border-width) solid var(--border);
  background: var(--surface);
}

/* Both conditions are required: the translucent background contains var(),
   so a browser with backdrop-filter but not color-mix (e.g. Safari < 16.2)
   would accept the declaration at parse time, fail it at computed-value
   time, and lose the background entirely instead of keeping the opaque
   fallback above. */
@supports ((backdrop-filter: blur(1rem)) or (-webkit-backdrop-filter: blur(1rem))) and
  (background: color-mix(in oklch, red 50%, transparent)) {
  .topbar {
    background: color-mix(in oklch, var(--surface) 88%, transparent);
    backdrop-filter: blur(0.75rem);
    -webkit-backdrop-filter: blur(0.75rem);
  }
}

/* Readable content measure. Composes with .center for centering or with
   margin-inline: auto for a one-off centered block.
 *
 * Public hooks:
 * --measure
 */
.measure {
  max-inline-size: var(--measure, 70ch);
}

/* Forms — the complete Forms family. base covers the ordinary native
   controls; the leaves below add opt-in control skins. switch.css follows
   choice.css (via base) so its rules land after the shared choice recipe.
   validation.css stays part of base, but custom builds may omit it when
   validation styling is app-owned. */
/* Forms base — the ordinary native controls bundle. */
/*
 * Form layout and supporting text.
 *
 * Individual controls keep their own files; .choice label layout lives in
 * choice.css; .form-actions lives in form-actions.css.
 *
 * .field-group owns its own box only. Distance between sibling groups is a
 * composition concern (.stack on the form), never a sibling margin here: the
 * component cannot know whether its siblings are stacked or gridded.
 *
 * Public API: .field, .field-label, .field-help, .field-error, .field-group,
 * .required-mark.
 *
 * Public hooks:
 * --fieldset-legend-padding-inline
 */
.field {
  display: grid;
  gap: var(--space-10);
  color: var(--text);
  font-size: var(--control-font-size);
}

.field-group {
  min-inline-size: 0;
  margin: 0;
  padding: var(--space-30);
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius);
}

.field-label,
.field-group > legend {
  color: var(--text);
  font-weight: var(--font-weight-semibold);
}

.field-group > legend {
  padding-block: 0;
  padding-inline: var(--fieldset-legend-padding-inline, var(--space-10));
  font-size: var(--font-size-xs);
}

.field-help {
  color: var(--text-muted);
  font-size: 0.875em;
}

.field-error {
  display: none;
  color: var(--danger);
  font-size: 0.875em;
}

.required-mark {
  color: var(--danger);
}

.field.danger .field-help {
  color: var(--danger);
}

/*
 * Text controls — input, textarea, select.
 *
 * Neutral geometry and interaction states shared across all text-like
 * controls. select shares this base recipe; its chevron/appearance layer
 * lives in select.css. checkbox, radio, range, switch, and file have their
 * own files.
 *
 * --control-* are the per-state control tokens, reserved for control geometry.
 * State selectors only flip tokens; non-text controls do not consume them, so
 * matching those selectors is a no-op. The .field wrapper uses --field-*
 * (form.css).
 *
 * --control-border-block-end-color/-width are unset extension points, mirroring
 * --btn-border-block-end-* (button.css), for themes that give text controls an
 * underline-style lower edge instead of a uniform border.
 *
 * Public hooks:
 * --control-border
 * --control-bg
 * --control-fg
 * --control-cursor
 * --control-border-block-end-color
 * --control-border-block-end-width
 */
.input,
.textarea,
.select {
  --control-border: var(--border);
  --control-bg: var(--surface);
  --control-fg: var(--text);
  --control-cursor: auto;

  appearance: none;
  -webkit-appearance: none;
  inline-size: 100%;
  min-block-size: var(--control-size);
  padding-inline: var(--control-pad-x);
  border: var(--border-width) solid var(--form-invalid-border, var(--control-border));
  border-block-end-color: var(
    --form-invalid-border,
    var(--control-border-block-end-color, var(--control-border))
  );
  border-block-end-width: var(--control-border-block-end-width, var(--border-width));
  border-radius: var(--radius);
  background: var(--control-bg);
  color: var(--control-fg);
  font-size: var(--control-font-size);
  cursor: var(--control-cursor);
  /* No outline: none here — the core :focus fallback must survive for browsers
     without :focus-visible. Pointer focus is hidden by core's
     :focus:not(:focus-visible); keyboard focus swaps in the transparent
     outline + ring below. */
  transition:
    border-color var(--duration),
    box-shadow var(--duration),
    background-color var(--duration),
    color var(--duration);
}

.textarea {
  min-block-size: 6rem;
  padding-block: var(--space-20);
  resize: vertical;
}

.input:hover:not(:disabled),
.textarea:hover:not(:disabled) {
  --control-border: var(--text-subtle);
}

.select:hover:not(:disabled) {
  --control-bg: var(--surface-subtle);
}

.input:focus-visible,
.textarea:focus-visible,
.select:focus-visible {
  --control-border: var(--focus);
  outline: 2px solid transparent;
  box-shadow: var(--focus-ring-shadow);
}

.input[readonly],
.textarea[readonly] {
  --control-bg: var(--surface-subtle);
  --control-border: var(--border);
}

.input:disabled,
.textarea:disabled,
.select:disabled {
  --control-bg: var(--surface-subtle);
  --control-fg: var(--state-disabled);
  --control-border: var(--border);
  --control-cursor: not-allowed;
}

.input::placeholder,
.textarea::placeholder {
  color: var(--text-muted);
}

/*
 * Checkbox and radio controls.
 *
 * Native inputs keep their behavior while sharing custom geometry, checked,
 * focus, and disabled states across semantic forms and classed aliases. This
 * file also owns the .choice label layout (input + label text) so the whole
 * concept lives in one file.
 *
 * Switches (input[role="switch"]) are excluded so switch.css owns their
 * geometry and states. switch.css is imported after this file.
 *
 * Public hooks:
 * --choice-control-offset
 * --choice-border
 * --choice-bg
 * --choice-mark
 *
 * --choice-control-size (tokens.css) is the shared typographic size for every
 * inline choice; switch.css derives its track from it so a checkbox and a
 * switch on the same line stay proportionate.
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.choice, .check, .radio) {
  --intent: initial;
  --intent-fg: initial;
}

/* A classed .choice is the component surface. Let its direct input consume a
   local intent without reopening inherited page-level intents. */
:where(.choice) > :where(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
  --intent: inherit;
  --intent-fg: inherit;
}

.choice {
  display: inline-flex;
  align-items: flex-start;
  gap: var(--space-20);
  color: var(--text);
  font-size: var(--control-font-size);
  cursor: pointer;
}

/* Optical alignment: nudge the control down so it sits visually centered with
   the first line of label text, not flush with the top of the line-box. Every
   inline choice runs the same formula — center the control's own block size on
   the first line box, then drop it 0.0625em so it reads level with the
   cap-height rather than the em-box. A checkbox and a switch therefore stay
   level with each other under any line-height or --choice-control-size, which
   a per-control constant cannot do (a taller switch and a shorter checkbox
   need different offsets to land on the same optical line).

   The constants are what the formula yields at the default 1.5 line-height:
   0.25em (4px) for a checkbox, 0.1875em (3px) for the taller switch track.
   They serve browsers without lh units. Multi-line labels still keep the
   control at the top thanks to align-items: flex-start.

   --choice-control-offset stays fallback-only rather than declared here, so an
   override placed anywhere up the tree — :root included — reaches the control
   instead of losing to a component-level declaration. */
.choice > :is(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
  flex: 0 0 auto;
  margin-block-start: var(--choice-control-offset, 0.25em);
}

.choice > .switch {
  flex: 0 0 auto;
  margin-block-start: var(--choice-control-offset, 0.1875em);
}

@supports (margin-block-start: 1lh) {
  .choice > :is(input[type="checkbox"]:not([role="switch"]), input[type="radio"]) {
    margin-block-start: var(
      --choice-control-offset,
      calc((1lh - var(--choice-control-size)) / 2 + 0.0625em)
    );
  }

  .choice > .switch {
    margin-block-start: var(
      --choice-control-offset,
      calc((1lh - var(--switch-block-size)) / 2 + 0.0625em)
    );
  }
}

.choice > span > .field-help {
  display: block;
}

/* Progressive enhancement: dim the whole row when one of its controls is
   disabled. Without :has() the native disabled control keeps its own state
   styling — the row-level dim is lost but the field stays clearly disabled. */
.choice:has(> :disabled) {
  color: var(--state-disabled);
  cursor: not-allowed;
}

/* Control geometry. --choice-* are the per-state tokens. */
.check,
.radio {
  --choice-border: var(--border);
  --choice-bg: var(--surface);
  --choice-mark: var(--primary-fg);

  position: relative;
  vertical-align: middle;
  display: inline-grid;
  place-content: center;
  inline-size: var(--choice-control-size);
  block-size: var(--choice-control-size);
  margin: 0;
  border: var(--border-width) solid var(--form-invalid-border, var(--choice-border));
  background: var(--choice-bg);
  color: var(--choice-mark);
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
}

.check {
  border-radius: var(--radius-sm);
}

.radio {
  border-radius: var(--radius-full);
}

.check::before,
.radio::before {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  transform: scale(0);
  transition: transform var(--duration);
}

.check::before {
  inline-size: 0.58em;
  block-size: 0.34em;
  border-inline-start: 2px solid currentColor;
  border-block-end: 2px solid currentColor;
  transform: translateY(-0.06em) rotate(-45deg) scale(0);
}

.radio::before {
  inline-size: 0.5em;
  block-size: 0.5em;
  border-radius: var(--radius-full);
  background: currentColor;
}

.check:is(:checked, :indeterminate),
.radio:checked {
  --choice-border: var(--intent, var(--state-selected));
  --choice-bg: var(--intent, var(--state-selected));
  --choice-mark: var(--intent-fg, var(--state-selected-fg));
}

.check:checked::before {
  transform: translateY(-0.06em) rotate(-45deg) scale(1);
}

.check:indeterminate::before {
  border-inline-start: 0;
  inline-size: 0.6em;
  block-size: 0;
  border-block-end: 2px solid currentColor;
  transform: scale(1);
}

.radio:checked::before {
  transform: scale(1);
}

/* Progressive: without :not() selector lists, the normal unchecked border
   remains visible and the control stays usable. */
.check:hover:not(:checked, :indeterminate, :disabled),
.radio:hover:not(:checked, :indeterminate, :disabled) {
  --choice-border: var(--text-subtle);
}

.check:focus-visible,
.radio:focus-visible {
  --choice-border: var(--focus);
  outline: 2px solid transparent;
  box-shadow: var(--focus-ring-shadow);
}

.check:disabled,
.radio:disabled {
  --choice-bg: var(--surface-subtle);
  --choice-border: var(--border);
  --choice-mark: var(--state-disabled);
  cursor: not-allowed;
  opacity: 1;
}

/* Forced colors intentionally stay under UA control: the visible check/dot
   carries state while native remapping preserves platform contrast and focus.
   If real HCM testing ever loses a mark, opt out on that pseudo only—never on
   the interactive input host. */

/*
 * Select — chevron and appearance layer on top of the shared text-control
 * recipe.
 *
 * Base geometry and interaction states come from control.css (select shares
 * the --control-* tokens). This file only removes the native arrow and draws
 * the CSS chevron, so the picker (appearance: base-select) enhancement in
 * custom-select.css can be opted out independently. Add .native to a specific
 * select to opt out of the enhancement.
 *
 * Public hooks:
 * --select-icon-size
 */
/* --select-icon-size is typographic (em), so the reserved slot and the
   pictogram scale with the control's font size, not a fixed rem sliver. */
.select:not(:is([multiple], [size]:not([size="1"]))) {
  --select-icon-size: 1em;

  appearance: none;
  -webkit-appearance: none;
  padding-inline-end: calc(var(--control-pad-x) * 2 + var(--select-icon-size));
  background-image: var(--icon-chevron);
  background-position: center right var(--control-pad-x);
  background-size: var(--select-icon-size) auto;
  background-repeat: no-repeat;
}

/* The chevron sits on the inline-end side; flip it in RTL so it follows
   the padding-inline-end space reserved for it. Safari before 16.4 has no
   :dir(); the ancestor-attribute path is the Degraded-tier fallback and the
   :dir() rules are dropped on their own there. :dir() follows inherited
   directionality, so the explicit ltr rule neutralizes an outer RTL fallback
   for a nested direction change. */
[dir="rtl"] .select:not(:is([multiple], [size]:not([size="1"]))) {
  background-position: center left var(--control-pad-x);
}

.select:not(:is([multiple], [size]:not([size="1"]))):dir(ltr) {
  background-position: center right var(--control-pad-x);
}

.select:not(:is([multiple], [size]:not([size="1"]))):dir(rtl) {
  background-position: center left var(--control-pad-x);
}

/* Both forced-colors and prefers-contrast hand the arrow back to the
   browser instead of guessing a higher-contrast recolor of our own icon —
   simpler than maintaining contrast variants, and native rendering already
   matches whatever contrast mode the user picked. */
@media (forced-colors: active), (prefers-contrast: more) {
  .select:not(:is([multiple], [size]:not([size="1"]))) {
    appearance: auto;
    background-image: none;
    padding-inline-end: var(--control-pad-x);
  }
}

/*
 * Native-only control polish: the file input and color picker mostly defeat
 * custom styling, so the framework stays out of the way and just keeps them
 * readable and theme-aware.
 *
 * Range has its own custom-styled file (range.css) so the focus ring can
 * target the thumb pseudo-element rather than the rectangular CSS box.
 */
.file {
  color: var(--text-muted);
  font-size: var(--control-font-size);
}

.file::file-selector-button {
  margin-inline-end: var(--space-30);
  /* Consume the shared control scale like .btn, so .sm/.lg and density resize
     the whole control, not just the filename text. */
  min-block-size: var(--control-size);
  padding-block: var(--space-10);
  padding-block: max(var(--space-10), calc((var(--control-size) - 1lh) / 2));
  padding-inline: var(--space-20);
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-subtle);
  color: var(--text);
  font: inherit;
  font-size: var(--control-font-size);
  font-weight: var(--font-weight-medium);
  cursor: pointer;
  transition:
    background-color var(--duration),
    border-color var(--duration),
    color var(--duration);
}

.file:disabled::file-selector-button {
  cursor: not-allowed;
}

.file:not(:disabled)::file-selector-button:hover {
  background: var(--border);
}

.file:focus-visible {
  outline: 2px solid transparent;
  box-shadow: var(--focus-ring-shadow);
}

/* Native color picker — keep the browser's dialog and selected-color UI. The
   swatch fills almost the whole control so it reads as a color, not as a tiny
   square inside a button; the 1px padding keeps a hair of --surface around it. */
.color {
  inline-size: var(--control-size);
  block-size: var(--control-size);
  padding: var(--border-width);
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  cursor: pointer;
}

.color::-webkit-color-swatch-wrapper {
  padding: 0;
}

.color::-webkit-color-swatch {
  border: 0;
  border-radius: calc(var(--radius) - var(--border-width));
}

.color::-moz-color-swatch {
  border: 0;
  border-radius: calc(var(--radius) - var(--border-width));
}

.color:disabled {
  cursor: not-allowed;
  opacity: var(--disabled-opacity);
}

.color:focus-visible {
  outline: 2px solid transparent;
  box-shadow: var(--focus-ring-shadow);
}

@media (forced-colors: active) {
  .color {
    border-color: ButtonText;
    background: ButtonFace;
  }

  /* The swatch color is content, so preserve it without disabling the host's
     native forced-color focus treatment. Vendor pseudos cannot share a rule. */
  .color::-webkit-color-swatch {
    forced-color-adjust: none;
  }

  .color::-moz-color-swatch {
    forced-color-adjust: none;
  }
}

/*
 * Form validation states.
 *
 * Imported by default through forms/index.css. Custom builds may omit this
 * file when an app wants to own validation styling completely.
 *
 * State coverage (which states each component ships) is audited in
 * tests/validation.test.js and summarized in docs/pages/forms/validation.md —
 * not kept as a source comment that can drift from the implementation.
 *
 * Public hooks:
 * --form-invalid-border
 *
 * Internal:
 * --choice-bg
 * --switch-bg
 */

.field.danger {
  --form-invalid-border: var(--danger);
}

.field.danger > .field-error {
  display: block;
}

@supports selector(:has(*)) {
  .field:has([aria-invalid="true"]) > .field-error,
  .needs-validation.was-validated .field:has(:invalid) > .field-error {
    display: block;
  }
}

:where(.input, .textarea, .select, .check, .radio, .switch)[aria-invalid="true"],
.needs-validation.was-validated :is(.input, .textarea, .select, .check, .radio, .switch):invalid {
  --form-invalid-border: var(--danger);
}

/* Override checked/indeterminate fill so both border and fill signal danger
   instead of a red border around a primary-colored control. All three
   invalid routes must converge: aria-invalid, was-validated :invalid, and
   :user-invalid. */
.check:is(:checked, :indeterminate)[aria-invalid="true"],
.needs-validation.was-validated .check:is(:checked, :indeterminate):invalid,
.radio:checked[aria-invalid="true"],
.needs-validation.was-validated .radio:checked:invalid {
  --choice-bg: var(--danger);
}

.switch:checked[aria-invalid="true"],
.needs-validation.was-validated .switch:checked:invalid {
  --switch-bg: var(--danger);
}

@supports selector(:user-invalid) {
  .needs-validation .check:is(:checked, :indeterminate):user-invalid,
  .needs-validation .radio:checked:user-invalid {
    --choice-bg: var(--danger);
  }

  .needs-validation .switch:checked:user-invalid {
    --switch-bg: var(--danger);
  }
}

/* Choice cards hide their native input, so invalid state must surface on the
   card itself. Sets the same --form-invalid-border hook the card's border
   consumes. */
@supports selector(:has(*)) {
  .choice-card:has([aria-invalid="true"]),
  .needs-validation.was-validated .choice-card:has(:invalid) {
    --form-invalid-border: var(--danger);
  }
}

@supports selector(:user-invalid) {
  .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
    --form-invalid-border: var(--danger);
  }
}

@supports selector(:has(:user-invalid)) {
  .needs-validation .field:has(:user-invalid) > .field-error {
    display: block;
  }

  .needs-validation .choice-card:has(:user-invalid) {
    --form-invalid-border: var(--danger);
  }
}

/*
 * Form actions — bottom form bar with sticky variant.
 *
 * A flex cluster of buttons/actions that closes a form. The sticky variant
 * docks to the viewport bottom with a translucent blur where supported; at
 * rest it stays visually neutral so it does not double as a separator.
 *
 * Public hooks:
 * --form-actions-align
 * --form-actions-justify
 * --form-actions-margin-block-start
 *
 * Internal:
 * --form-actions-sticky-block-offset (owned by .form-actions.sticky)
 * --form-actions-sticky-inline-offset (owned by .form-actions.sticky)
 * --form-actions-sticky-padding (owned by .form-actions.sticky)
 */
.form-actions {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--gap);
  align-items: var(--form-actions-align, center);
  justify-content: var(--form-actions-justify, flex-start);
  margin-block-start: var(--form-actions-margin-block-start, var(--space-50));
}

/* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
   surface and backdrop-blur are visually neutral. Content scrolling behind
   the bar reveals the blur as a natural separator instead of relying on a
   shadow. */
.form-actions.sticky {
  position: sticky;
  inset-block-end: var(--form-actions-sticky-block-offset, 0px);
  z-index: var(--z-sticky);
  isolation: isolate;

  margin-inline: calc(var(--form-actions-sticky-inline-offset, 0px) * -1);
  padding: var(--form-actions-sticky-padding, var(--space-30));

  border-block-start: var(--border-width) solid var(--border);
  background: var(--surface);
  -webkit-backdrop-filter: blur(1rem);
  backdrop-filter: blur(1rem);
}

/* The translucent surface needs @supports, not a double declaration: the
   value contains var(), so a browser without color-mix() cannot reject it at
   parse time — it wins the cascade, then fails at computed-value time and
   the background disappears instead of falling back to the opaque line. */
@supports (background: color-mix(in oklch, red 50%, transparent)) {
  .form-actions.sticky {
    background: color-mix(in oklch, var(--surface) 88%, transparent);
  }
}

/*
 * Input Icon — decorative icon inside a relative wrapper, leading or trailing.
 *
 * Icon-library agnostic: detects position by DOM order. First child that is
 * not .input = leading icon; icon after .input = trailing. The icon follows
 * local typography; density contexts shrink the control geometry around it
 * without changing the pictogram.
 *
 * Public hooks:
 * --input-icon-size
 */
.input-icon {
  --input-icon-size: 1.25em;

  display: block;
  position: relative;
  font-size: var(--control-font-size);
}

/* Icon — shared by leading and trailing */
.input-icon > :where(:not(.input)) {
  position: absolute;
  inset-block-start: 50%;
  transform: translateY(-50%);
  font-size: var(--input-icon-size);
  color: var(--text-muted);
  pointer-events: none;
}

/* Leading: icon is the first child */
.input-icon > :first-child:where(:not(.input)) {
  inset-inline-start: var(--control-pad-x);
}

/* Trailing: an icon follows the input, with or without a leading icon. */
.input-icon > .input ~ :where(:not(.input)) {
  inset-inline-end: var(--control-pad-x);
}

/* font-size only sizes text-metric icons (webfont <i>, <span>). An inline
   <svg> with just a viewBox ignores it, so give it explicit dimensions. */
.input-icon > svg:where(:not(.input)) {
  /* The shared rule already maps --input-icon-size onto font-size. One em is
     therefore the requested optical size; reusing the em-valued hook here
     would apply its factor a second time. */
  inline-size: 1em;
  block-size: 1em;
}

/* Leading: input gets start padding to clear the icon */
.input-icon > :first-child:where(:not(.input)) ~ .input {
  padding-inline-start: calc(var(--control-pad-x) * 2 + var(--input-icon-size));
}

/* Trailing: the input has a following sibling → end padding. Not tied to the
   input being first child, so a leading icon does not disable the trailing
   icon's reserved space. */
.input-icon > .input:where(:not(:last-child)) {
  padding-inline-end: calc(var(--control-pad-x) * 2 + var(--input-icon-size));
}

/*
 * Switch controls for checkbox inputs with switch semantics.
 *
 * The switch remains an input control, not a decorative toggle. Checked, hover,
 * focus-visible, and disabled states stay clear.
 *
 * Public hooks:
 * --switch-bg
 * --switch-border
 * --switch-knob
 * --switch-width
 * --switch-block-size
 * --switch-knob-margin
 * --switch-knob-size
 * --switch-travel
 *
 * The track is sized from --choice-control-size (tokens.css), not
 * --control-size: a switch sits on a line of text next to a checkbox, so it
 * follows the control typography rather than field density.
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.switch) {
  --intent: initial;
  --intent-fg: initial;
}

.switch {
  --switch-bg: var(--border);
  --switch-border: var(--border);
  --switch-knob: var(--surface-raised);
  --switch-block-size: calc(var(--choice-control-size) + 0.125em);
  --switch-width: calc(var(--switch-block-size) * 1.8);
  --switch-knob-margin: calc(var(--switch-block-size) * 0.08);

  /* The knob is concentric in the track: --switch-block-size is a border-box
     height, so the borders have to come out before the inset is applied on
     both axes. Deriving from the outer height instead leaves the knob
     --border-width closer to the rails than to the ends — a 1px error on a
     ~1.6px inset, which reads as a knob squeezed against the track. */
  --switch-knob-size: calc(
    var(--switch-block-size) -
    2 *
    var(--border-width) -
    2 *
    var(--switch-knob-margin)
  );
  --switch-travel: calc(
    var(--switch-width) -
    var(--switch-knob-size) -
    2 *
    var(--border-width) -
    2 *
    var(--switch-knob-margin)
  );

  position: relative;
  display: inline-grid;
  align-items: center;
  justify-content: start;
  inline-size: var(--switch-width);
  block-size: var(--switch-block-size);
  margin: 0;
  border: var(--border-width) solid var(--form-invalid-border, var(--switch-border));
  border-radius: var(--radius-full);
  background: var(--switch-bg);
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
}

/* No elevation on the knob: --shadow is a surface elevation (card, modal,
   drawer, fab), and at this size its 12px blur reads as a smudge that spills
   past the rail while carrying no state. The range thumb — the same kind of
   small moving part — stays flat for the same reason. The concentric inset
   against the track is what makes the knob legible. */
.switch::before {
  content: "";
  inline-size: var(--switch-knob-size);
  block-size: var(--switch-knob-size);
  margin-inline: var(--switch-knob-margin);
  border-radius: var(--radius-full);
  background: var(--switch-knob);
  transition:
    transform var(--duration),
    background-color var(--duration);
}

/* Hover is not selected: an unchecked switch keeps the brand fallback on hover,
   so a theme that separates --primary from --state-selected never tints an
   OFF control with the selection accent before it is checked. */
.switch:not(:checked):hover:not(:disabled) {
  --switch-border: var(--intent, var(--primary));
}

.switch:checked {
  --switch-bg: var(--intent, var(--state-selected));
  --switch-border: var(--intent, var(--state-selected));
  --switch-knob: var(--intent-fg, var(--state-selected-fg));
}

.switch:checked::before {
  transform: translateX(var(--switch-travel));
}

/* The knob travels toward inline-end; in RTL that is leftward, so the
    translateX sign flips. translateX is physical, not logical. Safari before
    16.4 has no :dir(); the ancestor-attribute path is the Degraded-tier
    fallback and the :dir() rules are dropped on their own there. */
[dir="rtl"] .switch:checked::before {
  transform: translateX(calc(var(--switch-travel) * -1));
}

.switch:checked:dir(ltr)::before {
  transform: translateX(var(--switch-travel));
}

.switch:checked:dir(rtl)::before {
  transform: translateX(calc(var(--switch-travel) * -1));
}

.switch:focus-visible {
  --switch-border: var(--focus);
  outline: 2px solid transparent;
  box-shadow: var(--focus-ring-shadow);
}

.switch:disabled {
  --switch-bg: var(--surface-subtle);
  --switch-border: var(--border);
  --switch-knob: var(--state-disabled);
  opacity: 1;
  cursor: not-allowed;
}

@media (forced-colors: active) {
  /* The host intentionally stays under UA control for platform contrast and
     focus. The custom knob has no native edge, so this system-color border
     keeps it distinct from the remapped track. If real HCM testing ever needs
     an opt-out, scope it to ::before—never to the interactive input host. */
  .switch::before {
    border: var(--border-width) solid ButtonText;
  }
}

/*
 * Range slider — custom-styled so the focus ring always targets the
 * user-manipulated surface (the thumb) rather than the CSS box.
 *
 * appearance: none gives us full control over track and thumb; without it,
 * pseudo-element rules for ::-webkit-slider-thumb and ::-moz-range-thumb
 * are not reliably applied across browsers.
 *
 * Public hooks:
 * --range-thumb-size
 * --range-track-height
 * --range-thumb-bg
 * --range-track-bg
 */

/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.range) {
  --intent: initial;
}

.range {
  --range-thumb-size: 1.25rem;
  --range-track-height: 0.25rem;
  --range-thumb-bg: var(--intent, var(--primary));
  --range-track-bg: var(--border);

  appearance: none;
  -webkit-appearance: none;
  inline-size: 100%;
  block-size: var(--range-thumb-size);
  background: transparent;
  cursor: pointer;
}

.range::-webkit-slider-runnable-track {
  block-size: var(--range-track-height);
  border-radius: var(--radius-full);
  background: var(--range-track-bg);
}

.range::-moz-range-track {
  block-size: var(--range-track-height);
  border-radius: var(--radius-full);
  background: var(--range-track-bg);
}

.range::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  inline-size: var(--range-thumb-size);
  block-size: var(--range-thumb-size);
  border-radius: var(--radius-full);
  border: 0;
  background: var(--range-thumb-bg);
  margin-block-start: calc((var(--range-track-height) - var(--range-thumb-size)) / 2);
  cursor: inherit;
  transition: box-shadow var(--duration);
}

.range::-moz-range-thumb {
  inline-size: var(--range-thumb-size);
  block-size: var(--range-thumb-size);
  border-radius: var(--radius-full);
  border: 0;
  background: var(--range-thumb-bg);
  cursor: inherit;
  transition: box-shadow var(--duration);
}

.range:focus-visible {
  outline: 2px solid transparent;
}

/*
 * The 1px surface-coloured separator keeps the ring visually distinct from
 * the small, coloured thumb — unlike a full-size input where the same gap
 * would read as an unwanted void.
 */
.range:focus-visible::-webkit-slider-thumb {
  box-shadow:
    0 0 0 1px var(--surface),
    0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
}

.range:focus-visible::-moz-range-thumb {
  box-shadow:
    0 0 0 1px var(--surface),
    0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
}

.range:disabled {
  --range-thumb-bg: var(--state-disabled);
  cursor: not-allowed;
  opacity: var(--disabled-opacity);
}

/* Forced colors: the track must stay a visible line against the page (Canvas
   would merge with it) and the thumb needs a real boundary to remain
   distinguishable from the track. Disabled keeps GrayText via :disabled.
   Focus stays on the transparent outline of :focus-visible. */
@media (forced-colors: active) {
  .range {
    --range-track-bg: CanvasText;
    --range-thumb-bg: ButtonFace;
  }

  .range::-webkit-slider-thumb {
    border: var(--border-width) solid ButtonText;
  }

  .range::-moz-range-thumb {
    border: var(--border-width) solid ButtonText;
  }
}

/*
 * Choice cards — card-style radio and checkbox labels.
 *
 * Each .choice-card is a <label> wrapping a native radio or checkbox. When
 * :has() is supported the input is visually hidden and the card mirrors its
 * state: checked border and tint, checkmark badge (round for radio, rounded
 * square for checkbox), focus ring, disabled dimming. Without :has() the
 * native input stays visible so selection state never disappears.
 *
 * The badge overhangs the top-right corner by 0.375em; ancestors with
 * overflow: hidden will clip it. Pair with grid or flex utilities for group
 * layout.
 *
 * Public hooks:
 * --choice-card-pad
 * --choice-card-radius
 * --choice-card-border
 * --choice-card-bg
 * --choice-card-check-size
 */
:where(.choice-card) {
  --intent: initial;
  --intent-fg: initial;
}

.choice-card {
  --choice-card-pad: var(--density-space, var(--space-40));
  --choice-card-radius: var(--radius);
  --choice-card-border: var(--border);
  --choice-card-bg: var(--surface);
  --choice-card-check-size: 1.5em;

  position: relative;
  display: block;
  padding: var(--choice-card-pad);
  border: var(--border-width) solid var(--form-invalid-border, var(--choice-card-border));
  border-radius: var(--choice-card-radius);
  background: var(--choice-card-bg);
  color: var(--text);
  cursor: pointer;
  transition:
    border-color var(--duration),
    background-color var(--duration),
    box-shadow var(--duration);
}

/* The native input is visually hidden but stays focusable and participates in
   form submission and keyboard navigation (sr-only pattern). Guarded: without
   :has() the card cannot mirror the input's state, so the visible native
   control is the fallback affordance. */
@supports selector(:has(*)) {
  .choice-card > :is(input[type="radio"], input[type="checkbox"]) {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
}

/* Focus ring on the label follows the hidden input's focus. */
.choice-card:has(:focus-visible) {
  outline: 2px solid transparent;
  box-shadow: var(--focus-ring-shadow);
}

.choice-card:has(:disabled) {
  opacity: var(--disabled-opacity);
  cursor: not-allowed;
}

.choice-card:hover:not(:has(:disabled)) {
  --choice-card-border: var(--text-subtle);
}

/* Checked border — shared by radio and checkbox */
.choice-card:has(:checked) {
  --choice-card-border: var(--intent, var(--state-selected));
}

/* Subtle background tint on checked — modern browsers only */
@supports (color: color-mix(in oklch, red, white)) {
  .choice-card:has(:checked) {
    --choice-card-bg: color-mix(in oklab, var(--surface) 96%, var(--intent, var(--state-selected)));
  }
}

/* Checkmark badge — always rendered at scale(0) and scaled in on check, so
   the entrance can transition (a pseudo created on :checked could not). */
.choice-card::before,
.choice-card::after {
  content: "";
  position: absolute;
  inset-inline-end: -0.375em;
  inset-block-start: -0.375em;
  inline-size: var(--choice-card-check-size);
  block-size: var(--choice-card-check-size);
  transform: scale(0);
  transition: transform var(--duration);
}

/* Badge disc — circle for radio, rounded square for checkbox */
.choice-card::before {
  background: var(--intent, var(--state-selected));
  box-shadow: 0 0 0 2px var(--surface);
  z-index: 1;
}

.choice-card:has(input[type="radio"])::before {
  border-radius: var(--radius-full);
}

.choice-card:has(input[type="checkbox"])::before {
  border-radius: var(--radius-sm);
}

/* Check mark */
.choice-card::after {
  background: var(--intent-fg, var(--state-selected-fg));
  -webkit-mask: var(--icon-check) center / 60% no-repeat;
  mask: var(--icon-check) center / 60% no-repeat;
  z-index: 2;
}

.choice-card:has(:checked)::before,
.choice-card:has(:checked)::after {
  transform: scale(1);
}

/* The native input is hidden and the checked marker is custom-drawn: without
   this repair, forced colors pushes disc and check to the same system color
   and the state disappears. Highlight/Canvas/HighlightText are preserved as-is,
   so no forced-color-adjust is needed. The pseudo is not focusable — the
   outline is a separator against the card, not a focus channel. */
@media (forced-colors: active) {
  .choice-card:has(:checked)::before {
    background: Highlight;
    outline: 2px solid Canvas;
  }

  .choice-card:has(:checked)::after {
    background: HighlightText;
  }
}

/*
 * Customizable select — appearance: base-select picker enhancement.
 *
 * Progressive enhancement on top of the native .select baseline in select.css.
 * Wrapped in @supports and gated on fine pointer devices so it never blocks a
 * layout. Add .native to a specific select to opt out.
 *
 * No layout or interaction contract may depend on appearance: base-select.
 * Arbitrary menu and popover behavior belongs elsewhere.
 *
 * Internal:
 * --control-border
 * --control-bg
 */
/* This enhancement is excluded from forced-colors, where the native/shared
   outline remains the focus fallback. */
@media (forced-colors: none) and (prefers-contrast: no-preference) and (hover: hover) and (
    pointer: fine
  ) {
  @supports (appearance: base-select) {
    .select:not([multiple], .native, [size]:not([size="1"])) {
      appearance: base-select;
      align-items: center;

      &::picker(select) {
        appearance: base-select;
        max-block-size: min(24rem, calc(var(--viewport-block) * 0.7));
        margin-block: var(--space-20);
        padding: 0;
        border: var(--border-width) solid var(--border);
        border-radius: var(--radius);
        background-color: var(--surface-raised);
        color: inherit;
        box-shadow: var(--shadow-popout);
      }

      &:open {
        --control-border: var(--border);
        --control-bg: var(--surface-raised);
        outline: none;
        box-shadow: var(--focus-ring-shadow);
      }

      &::picker-icon {
        display: none;
      }

      & option {
        position: relative;
        padding-block: var(--space-20);
        padding-inline: var(--space-30);
        white-space: normal;
        transition-property: color, background-color;
        transition-duration: var(--duration);

        &:not(:disabled):hover {
          background-color: var(--surface-subtle);
          color: var(--text);
          cursor: pointer;
        }

        &:not(:disabled):focus-visible {
          background-color: var(--primary);
          color: var(--primary-fg);
          outline: none;
        }

        &:active {
          background-color: var(--surface-subtle);
          color: var(--text);
        }

        &::checkmark {
          content: "";
          display: block;
          inline-size: 1rem;
          block-size: 1rem;
          background-color: currentColor;
          -webkit-mask: var(--icon-check) center / contain no-repeat;
          mask: var(--icon-check) center / contain no-repeat;
        }
      }

      & optgroup {
        padding-block-start: 0.5em;

        & option:first-child {
          margin-block-start: 0.5em;
        }
      }
    }
  }
}

/*
 * Floating labels — optional composition over the text controls
 * (.input, .textarea, .select) and .field-label.
 *
 * The cell owns a control plus its label; the label overlays the control and
 * floats to the top when the control is focused or non-empty. Help and error
 * messages stay in the surrounding .field, not inside the cell, so the
 * resting label can center on the control alone.
 *
 * The control must precede the label: the float is driven by the general
 * sibling combinator and :placeholder-shown. Injected nodes may appear between
 * the control and the label (password managers, extension wrappers); the cell
 * contract stays a single control plus one label. Every .input/.textarea needs
 * placeholder=" " to expose a reliable empty state — without it the label
 * only reacts to focus.
 *
 * Density flows through --control-size: .compact/.spacious change the control
 * geometry and the reserved headroom with it, leaving typography untouched.
 *
 * Public hooks:
 * --floating-label-top
 * --floating-label-opacity
 * --floating-pad-block-start
 */
.floating-field {
  --floating-label-top: var(--space-10);
  --floating-label-opacity: 0.8;
  --floating-pad-block-start: calc(var(--control-size) / 2 + var(--space-20));

  /* Grid blockifies the control, removing the inline descender gap a textarea
     would otherwise leave under itself. */
  position: relative;
  display: grid;
}

.floating-field > :is(.input, .textarea, .select) {
  /* Headroom for the floated label is always reserved, so the value never
     jumps when the label floats. Half the control centers the resting label;
     the extra step clears the floated label's bottom edge. */
  padding-block-start: var(--floating-pad-block-start);
  padding-block-end: var(--space-10);
}

.floating-field > .field-label {
  position: absolute;
  inset-inline-start: calc(var(--space-30) + var(--border-width));
  color: var(--text-muted);
  font-size: var(--control-font-size);
  font-weight: var(--font-weight-normal);
  opacity: var(--floating-label-opacity);
  pointer-events: none;
  transition:
    inset-block-start var(--duration),
    transform var(--duration),
    font-size var(--duration),
    opacity var(--duration),
    color var(--duration);
}

/* Single-line controls rest in the middle until they receive a value. */
.floating-field > :is(.input, .select) ~ .field-label {
  inset-block-start: 50%;
  transform: translateY(-50%);
}

/* An empty textarea label follows the control's normal first-line position;
   the control itself keeps the extra headroom reserved for its floated state. */
.floating-field > .textarea ~ .field-label {
  inset-block-start: calc(1em + var(--border-width));
  transform: none;
}

/* :autofill covers prefilled values before interaction, when the browser may
   not have propagated the value to the placeholder state yet. */
.floating-field
  > :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown))
  ~ .field-label {
  inset-block-start: var(--floating-label-top);
  transform: none;
  font-size: var(--font-size-xs);
  opacity: 1;
}

/* Controls without a dependable :placeholder-shown empty state stay floated:
   select always, and date/time inputs whose placeholder rendering is
   inconsistent across engines (Firefox shows none). */
.floating-field
  > :is(
    .select,
    .input[type="date"],
    .input[type="time"],
    .input[type="datetime-local"],
    .input[type="month"],
    .input[type="week"]
  )
  ~ .field-label {
  inset-block-start: var(--floating-label-top);
  transform: none;
  font-size: var(--font-size-xs);
  opacity: 1;
}

@media (forced-colors: active) {
  .floating-field > .field-label {
    opacity: 1;
  }
}

/*
 * One-time code input with visual cells and one native input.
 *
 * Add one direct span per accepted character. The cells determine the
 * component width; the overlaid input remains the only interactive control.
 *
 * Public hooks:
 * --otp-cell-size
 * --otp-gap
 * --otp-font-size
 *
 * Internal:
 * --otp-cell-border
 * --otp-cell-border-width
 * --otp-cell-bg
 * --otp-caret-space
 */
.otp {
  --otp-cell-size: var(--control-size);
  --otp-gap: var(--space-10);
  --otp-font-size: 1.5em;
  --otp-cell-border: var(--border);
  --otp-cell-bg: var(--surface);
  --otp-caret-space: calc((var(--otp-cell-size) - 1ch) / 2 + var(--otp-gap) + 1px);

  position: relative;
  display: inline-grid;
  grid-auto-flow: column;
  grid-auto-columns: var(--otp-cell-size);
  inline-size: fit-content;
  max-inline-size: 100%;
  gap: var(--otp-gap);
  border-radius: var(--radius);
  font-size: var(--control-font-size);
  /* Unlike hidden, clip cannot become a scroll container when the caret moves
     into the input extension. */
  overflow: clip;
  direction: ltr;
}

.otp > span {
  inline-size: var(--otp-cell-size);
  block-size: var(--otp-cell-size);
  border: var(--otp-cell-border-width, var(--border-width)) solid var(--otp-cell-border);
  border-radius: var(--radius);
  background: var(--otp-cell-bg);
  transition:
    border-color var(--duration),
    background-color var(--duration);
}

/* The native input owns focus, selection, autofill, paste, and one-time-code
   integration. Its clipped extension accommodates the trailing letter-space
   and final caret so the browser never scrolls the glyphs out of alignment. */
.otp > input {
  position: absolute;
  z-index: 1;
  inset: 0;
  inline-size: calc(100% + var(--otp-caret-space));
  block-size: 100%;
  margin: 0;
  padding-block: 0;
  padding-inline: calc((var(--otp-cell-size) - 1ch) / 2) 0;
  border: 0;
  outline: 2px solid transparent;
  box-shadow: none;
  background: transparent;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: var(--otp-font-size);
  font-variant-numeric: tabular-nums;
  line-height: var(--otp-cell-size);
  letter-spacing: calc(var(--otp-cell-size) + var(--otp-gap) - 1ch);
  text-align: start;
  appearance: none;
  -webkit-appearance: none;
}

/* Deliberately no group ring: a box around all the cells reads as a second
   component. The caret already carries focus while the code is being typed,
   but it sits in the clipped input extension once every cell is filled, so
   the focused edge must be unambiguous on its own — hence the doubled width,
   not only the color swap. Width is safe to change: box-sizing is border-box
   and the cells have a fixed inline/block size, so nothing reflows.

   The <input> must be the first direct child, with one cell <span> per
   character after it: the state rules select the cells as following siblings
   (input:focus ~ span, input:disabled ~ span, …), so that order is part of the
   contract. */
.otp > input:focus ~ span {
  --otp-cell-border: var(--focus);
  --otp-cell-border-width: calc(var(--border-width) * 2);
}

.otp > input[aria-invalid="true"] ~ span,
.needs-validation.was-validated .otp > input:invalid ~ span {
  --otp-cell-border: var(--danger);
}

@supports selector(:user-invalid) {
  .needs-validation .otp > input:user-invalid ~ span {
    --otp-cell-border: var(--danger);
  }
}

.otp > input:disabled {
  color: var(--state-disabled);
  cursor: not-allowed;
}

/* biome-ignore lint/style/noDescendingSpecificity: disabled must come last of
   the cell states, and the reorder the rule asks for would invert that. A
   disabled input is barred from constraint validation, so `:invalid` and
   `:focus` cannot reach it at all; the only overlap left is an authored
   aria-invalid, where equal specificity and source order are what make
   disabled — not danger — the colour a dead field reads as. */
.otp > input:disabled ~ span {
  --otp-cell-bg: var(--surface-subtle);
  --otp-cell-border: var(--state-disabled);
  opacity: var(--disabled-opacity);
}

@media (forced-colors: active) {
  .otp > input:focus ~ span {
    --otp-cell-border: Highlight;
  }

  .otp > input[aria-invalid="true"] ~ span,
  .needs-validation.was-validated .otp > input:invalid ~ span {
    --otp-cell-border: Mark;
  }
}

/* Controls */
/*
 * Buttons for actions and button-like links.
 *
 * .btn is primarily for actionable elements: <button>, <a href>,
 * input[type="button"|"submit"|"reset"].
 *
 * Keeps shared intent, variant, and size behavior with a solid default.
 *
 * Disabled state uses the native disabled attribute for buttons and inputs.
 * Anchors have no native disabled state, so a.btn uses aria-disabled="true"
 * for disabled styling and activation guards.
 *
 * Public hooks:
 * --btn-font-size
 * --btn-min-size
 * --btn-pad-x
 * --btn-radius
 * --btn-focus-color
 * --btn-gap
 * --btn-hover-overlay
 * --btn-default-bg
 * --btn-default-fg
 * --btn-default-border
 * --btn-border-color
 *
 * Internal:
 * --btn-bg
 * --btn-fg
 * --btn-border
 * --btn-default-hover-bg
 * --btn-focus-ring-color
 * --btn-border-block-end-color
 * --btn-border-block-end-width
 * --spinner-width
 */

/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.btn) {
  --intent: initial;
  --intent-fg: initial;
  --intent-soft-fg: initial;
  --ui-bg: initial;
  --ui-fg: initial;
  --ui-border: initial;
  --ui-hover-bg: initial;
}

.btn {
  --btn-font-size: var(--control-font-size);
  --btn-min-size: var(--control-size);
  --btn-pad-x: var(--control-pad-x, 1em);
  --btn-bg: var(--ui-bg, var(--btn-default-bg, var(--intent, var(--neutral))));
  --btn-fg: var(--ui-fg, var(--btn-default-fg, var(--intent-fg, var(--neutral-fg))));
  --btn-border: var(--ui-border, var(--btn-default-border, transparent));
  --btn-default-hover-bg: var(--btn-bg);
  --btn-radius: var(--radius);
  --btn-focus-color: var(--intent, var(--focus));
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));

  /* Positioning context for indicators, overlays, and grouped states. */
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--btn-gap, 0.375em);
  min-block-size: var(--btn-min-size);
  padding-block: var(--space-20);
  padding-block: max(var(--space-10), calc((var(--btn-min-size) - 1lh) / 2));
  padding-inline: var(--btn-pad-x);
  border: var(--border-width) solid var(--btn-border-color, var(--btn-border));
  border-block-end-color: var(
    --btn-border-block-end-color,
    var(--btn-border-color, var(--btn-border))
  );
  border-block-end-width: var(--btn-border-block-end-width, var(--border-width));
  border-radius: var(--btn-radius);
  background: var(--btn-bg);
  color: var(--btn-fg);
  font: inherit;
  font-size: var(--btn-font-size);
  font-weight: var(--font-weight-medium);
  line-height: 1;
  text-decoration: none;
  cursor: pointer;
  transition:
    background-color var(--duration),
    border-color var(--duration),
    color var(--duration),
    box-shadow var(--duration),
    transform var(--duration);
}

.btn.icon-only {
  inline-size: var(--btn-min-size);
  block-size: var(--btn-min-size);
  padding: 0;
}

@supports (color: color-mix(in oklch, red, white)) {
  .btn {
    --btn-focus-ring-color: color-mix(in oklch, var(--btn-focus-color) 28%, transparent);
  }
}

/* .solid/.soft/.outline are shared via variants.css. The baseline --btn-*
   fallbacks make .btn solid by default; .ghost/.link are .btn-only variants. */
.btn.ghost {
  --ui-bg: transparent;
  --ui-fg: var(--intent, currentColor);
  --ui-border: transparent;
}

/* Solid keeps the overlay darkening on hover; soft/outline/ghost/link stay
   flat. The soft hover is its own fill change (--soft-hover-alpha) — a second
   overlay on top would double the fill step and drag its ink below the resting
   contrast on every intent. Routed through a variable instead of a
   :not(.outline, .ghost, .link) exclusion so the hover rule below only has to
   state the interactive element list once. */
.btn.outline,
.btn.ghost,
.btn.link,
.btn.soft {
  --btn-hover-overlay: transparent;
}

/* Shared hover recipe — sets --ui-hover-bg consumed by the hover rule below.
   Declared outside :hover: the variables resolve only when the hover rule
   reads them, so the values are inert until interaction. */
.btn:is(.outline, .ghost) {
  --ui-hover-bg: transparent;
}

@supports (color: color-mix(in oklch, red, white)) {
  .btn {
    --btn-default-hover-bg: color-mix(in oklch, var(--btn-bg) 88%, black);
  }

  .btn.soft {
    --ui-hover-bg: color-mix(
      in oklab,
      var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
      var(--intent, var(--neutral))
    );
  }

  .btn.outline,
  .btn.ghost {
    --ui-hover-bg: color-mix(in oklch, currentColor 10%, transparent);
  }
}

/* The .soft/.outline/.ghost hover recipe (--ui-hover-bg) is set locally above;
   the overlay darkening for solid/soft is .btn-specific. */
.btn:not(:where(:disabled, [aria-disabled="true"])):hover {
  background: var(--ui-hover-bg, var(--btn-default-hover-bg));
  /* Huge inset spread paints the overlay over any border-radius without a
     pseudo-element; a plain background would replace the fill instead of
     darkening it. */
  box-shadow: inset 0 0 0 999px var(--btn-hover-overlay);
}

.btn:not(:where(:disabled, [aria-disabled="true"])):active {
  transform: translateY(1px);
}

.btn.link {
  --ui-bg: transparent;
  --ui-fg: var(--intent, currentColor);
  --ui-border: transparent;
  --btn-radius: var(--radius-sm);

  min-block-size: auto;
  padding-inline: 0;
  inline-size: fit-content;
  max-inline-size: 100%;
  /* No border at all: a link reads through its underline, and the inherited
     .btn border made transparent would turn into a visible box in forced
     colors. :focus-visible keeps its own outline. */
  border: 0;
  text-decoration: underline;
  text-underline-offset: 0.18em;
}

/* .btn.circle needs no local rule: the .circle utility (utilities.css) loads
   after components and overrides border-radius at equal specificity. */

.btn.link:not(:where(:disabled, [aria-disabled="true"])):hover {
  background: transparent;
  text-decoration-thickness: 0.12em;
}

.btn:focus-visible {
  outline: 2px solid transparent;
  box-shadow: 0 0 0 var(--focus-ring-width) var(--btn-focus-ring-color, var(--focus-ring));
}

.btn.link:focus-visible {
  outline: var(--focus-outline);
  outline-offset: 0.2em;
  box-shadow: none;
}

.btn:disabled {
  cursor: not-allowed;
  opacity: var(--disabled-opacity);
}

/* Anchors have no native disabled state.
   a.btn[aria-disabled="true"] only provides disabled styling and blocks pointer
   activation. For a truly disabled link, remove href or prevent activation in
   app code. */
a.btn[aria-disabled="true"] {
  cursor: not-allowed;
  opacity: var(--disabled-opacity);
  pointer-events: none;
}

/* Busy */
.btn[aria-busy="true"]:not(:disabled) {
  cursor: progress;
}

.btn[aria-busy="true"] > .spinner {
  --spinner-width: 0.15em;
}

/* Toggle and current state.
   State must outrank the shared .outline/.ghost hover recipes: a pressed or
   current button keeps its intent background and foreground while hovered.
   aria-current="page" is the pagination current-page marker; aria-pressed
   covers toggle buttons. */
.btn:is([aria-pressed="true"], [aria-current="page"]):not(.link) {
  --btn-bg: var(--intent, var(--state-selected));
  --btn-fg: var(--intent-fg, var(--state-selected-fg));
  --btn-border: var(--intent, var(--state-selected));
  --ui-hover-bg: var(--btn-bg);
}

@media (forced-colors: active) {
  a.btn[aria-disabled="true"] {
    color: GrayText;
    border-color: GrayText;
  }
}

/*
 * Badges for compact labels, counts, and state hints.
 *
 * Soft by default. .solid for counters and notification badges. The dot
 * (.badge:empty) stays solid regardless — a soft dot is pointless.
 * Shared .outline variant stays available.
 *
 * Public hooks:
 * --badge-size
 * --badge-pad-x
 * --badge-font-size
 * --badge-icon-size
 * --badge-dot-size
 * --badge-radius
 * --badge-dismiss-icon-size
 *
 * Internal:
 * --badge-default-bg
 * --badge-default-fg
 * --badge-default-border
 * --badge-default-hover-bg
 * --badge-bg
 * --badge-fg
 * --badge-border
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.badge) {
  --intent: initial;
  --intent-fg: initial;
  --intent-soft-fg: initial;
  --ui-bg: initial;
  --ui-fg: initial;
  --ui-border: initial;
  --ui-hover-bg: initial;
}

.badge {
  --badge-size: 1.5rem;
  /* A third of the height keeps the pill proportional across local sizes
     without coupling it to the label typography. */
  --badge-pad-x: calc(var(--badge-size) / 3);
  --badge-font-size: var(--font-size-sm);
  --badge-icon-size: 1em;
  --badge-dot-size: 0.5rem;
  --badge-dismiss-icon-size: 0.875em;
  --badge-default-bg: var(--surface-subtle);
  --badge-default-fg: var(--intent, var(--text));
  --badge-default-border: var(--border);
  --badge-default-hover-bg: var(--badge-bg);
  --badge-bg: var(--ui-bg, var(--badge-default-bg));
  --badge-fg: var(--ui-fg, var(--badge-default-fg));
  --badge-border: var(--ui-border, var(--badge-default-border));
  --badge-radius: var(--radius-full);

  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: middle;
  gap: 0.35em;
  /* Content-sized: a flex parent must not squeeze the pill and push its
     nowrap label outside the background. */
  flex: none;
  inline-size: fit-content;
  max-inline-size: 100%;
  min-block-size: var(--badge-size);
  padding-inline: var(--badge-pad-x);
  border: var(--border-width) solid var(--badge-border);
  border-radius: var(--badge-radius);
  background: var(--badge-bg);
  color: var(--badge-fg);
  font-size: var(--badge-font-size);
  font-weight: var(--font-weight-medium);
  line-height: 1;
  text-decoration: none;
  white-space: nowrap;
  transition:
    background-color var(--duration),
    border-color var(--duration),
    color var(--duration),
    box-shadow var(--duration);
}

.badge.sm {
  --badge-size: 1.25rem;
  --badge-font-size: var(--font-size-xs);
  --badge-dot-size: 0.375rem;
}

.badge.lg {
  --badge-size: 1.875rem;
  --badge-font-size: var(--font-size-md);
  --badge-dot-size: 0.625rem;
}

/* Decorative children follow the badge's optical size. Text and interactive
   dismiss buttons retain their own boxes. */
.badge > :where(svg, img, [aria-hidden="true"]:not(.dot)) {
  flex: none;
  /* The shared rule maps --badge-icon-size onto font-size. One em is therefore
     the requested optical size; reusing the em-valued hook here would apply its
     factor a second time. */
  inline-size: 1em;
  block-size: 1em;
  font-size: var(--badge-icon-size);
  line-height: 1;
}

/* Soft by default. Local defaults remain behind --ui-* in the resolution
   chain, so explicit shared treatments compose without selector exclusions. */
@supports (color: color-mix(in oklch, red, white)) {
  .badge {
    /* @sync soft-recipe */
    --badge-default-bg: color-mix(
      in oklab,
      var(--surface) var(--soft-bg-mix),
      var(--intent, var(--neutral))
    );
    --badge-default-border: color-mix(
      in oklab,
      var(--surface) var(--soft-border-mix),
      var(--intent, var(--neutral))
    );
    --badge-default-fg: var(
      --intent-soft-fg,
      color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
    );
    /* @sync soft-recipe */
  }
}

.badge:empty {
  --badge-size: var(--badge-dot-size);
  --badge-bg: var(--intent, var(--neutral));
  --badge-fg: var(--intent-fg, var(--neutral-fg));
  --badge-border: transparent;
  padding: 0;
  border: 0;
  inline-size: var(--badge-size);
  block-size: var(--badge-size);
}

/* The shared .dot separator doubles as a status dot inside a badge.
   currentColor follows the badge's resolved ink, so the dot matches a soft
   intent without repeating the intent class on it. */
.badge > .dot {
  background-color: currentColor;
}

.badge > button {
  display: inline-grid;
  place-items: center;
  flex: 0 0 auto;
  /* WCAG 2.5.8 floors pointer targets at 24 CSS px. Density and local size may
     keep the badge visually compact through the negative margins, but never
     shrink its action target below that literal floor. */
  min-inline-size: max(var(--badge-size), 24px);
  min-block-size: max(var(--badge-size), 24px);
  margin-block: -0.25rem;
  margin-inline-start: -0.15em;
  margin-inline-end: -0.45em;
  padding: 0.25rem;
  border: 0;
  border-radius: var(--radius-full);
  background: transparent;
  color: inherit;
  font: inherit;
  line-height: 1;
  cursor: pointer;
}

/* Dismiss mark — an empty dismiss button paints its own X from the shared
   --icon-close mask, so no icon font or text glyph is required in the markup
   (same contract as .alert-dismiss and .dialog-close). A mask box is a block
   box, so place-items centers the ink itself; a text glyph would only center
   its line box and land the ink low, because a lowercase x has no descender
   and only x-height of ascent. A button with its own content keeps it. */
.badge > button:empty::before {
  content: "";
  inline-size: var(--badge-dismiss-icon-size);
  block-size: var(--badge-dismiss-icon-size);
  background: currentColor;
  -webkit-mask: var(--icon-close) center / contain no-repeat;
  mask: var(--icon-close) center / contain no-repeat;
}

.badge > button:not(:disabled):hover {
  background: var(--hover-overlay);
}

/* Hover recipes — sets --ui-hover-bg consumed by the hover rule below.
   Declared outside :hover like button.css for the same reason. */
:is(a.badge, button.badge).outline {
  --ui-hover-bg: transparent;
}

@supports (color: color-mix(in oklch, red, white)) {
  :is(a.badge, button.badge):is(.solid, :empty) {
    --ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
  }

  :is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
    --badge-default-hover-bg: color-mix(
      in oklab,
      var(--surface) calc(var(--soft-bg-mix) - var(--soft-hover-alpha)),
      var(--intent, var(--neutral))
    );
  }

  :is(a.badge, button.badge).outline {
    --ui-hover-bg: color-mix(in oklch, currentColor 10%, transparent);
  }
}

:where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"])):hover {
  background: var(--ui-hover-bg, var(--badge-default-hover-bg));
}

/* Solid and empty badges keep the overlay darkening on hover; soft badges do
   not — their hover fill (--soft-hover-alpha) is already the state change, and
   stacking a second darkening drags the soft ink below its resting contrast.
   Soft here means :not(.solid, .outline, :empty), mirroring the soft hover-bg
   recipe above. */
:where(a.badge, button.badge):is(.solid, :empty):not(
  :where(:disabled, [aria-disabled="true"])
):hover {
  /* Huge inset spread paints the overlay over the pill radius (see button.css). */
  box-shadow: inset 0 0 0 999px var(--hover-overlay);
}

button.badge {
  cursor: pointer;
}

button.badge:disabled,
.badge[aria-disabled="true"] {
  opacity: var(--disabled-opacity);
  cursor: not-allowed;
}

a.badge[aria-disabled="true"] {
  pointer-events: none;
}

@media (forced-colors: active) {
  .badge:empty {
    background: ButtonText;
  }

  .badge[aria-disabled="true"] {
    color: GrayText;
    border-color: GrayText;
  }
}
@media print {
  .badge {
    border: var(--border-width) solid CanvasText;
  }
}

/*
 * Alerts for inline feedback and status messages.
 *
 * Keeps alert defaults local while consuming the shared --ui-* variant
 * plumbing for emphasis.
 *
 * --alert-border-inline-start-color/-width are unset extension points, mirroring
 * --btn-border-block-end-* (button.css), for themes that give alerts a
 * callout-style colored flag on the leading edge instead of a uniform border.
 *
 * Public hooks:
 * --alert-pad-block
 * --alert-pad-inline
 * --alert-font-size
 * --alert-radius
 * --alert-icon-size
 * --alert-dismiss-size
 * --alert-border-inline-start-color
 * --alert-border-inline-start-width
 *
 * Internal:
 * --alert-default-bg
 * --alert-default-fg
 * --alert-default-border
 * --alert-bg
 * --alert-fg
 * --alert-border
 * --alert-dismiss-icon-size
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.alert) {
  --intent: initial;
  --intent-fg: initial;
  --intent-soft-fg: initial;
  --ui-bg: initial;
  --ui-fg: initial;
  --ui-border: initial;
}

.alert {
  --alert-pad-block: var(--space-30);
  --alert-pad-inline: var(--space-40);
  --alert-font-size: var(--font-size-md);
  --alert-icon-size: 1.25rem;
  --alert-default-bg: var(--surface-subtle);
  --alert-default-fg: var(--intent, var(--text));
  --alert-default-border: var(--border);
  --alert-bg: var(--ui-bg, var(--alert-default-bg));
  --alert-fg: var(--ui-fg, var(--alert-default-fg));
  --alert-border: var(--ui-border, var(--alert-default-border));

  padding-block: var(--alert-pad-block);
  padding-inline: var(--alert-pad-inline);
  background: var(--alert-bg);
  color: var(--alert-fg);
  border: var(--border-width) solid var(--alert-border);
  border-inline-start-color: var(--alert-border-inline-start-color, var(--alert-border));
  border-inline-start-width: var(--alert-border-inline-start-width, var(--border-width));
  border-radius: var(--alert-radius, var(--radius-lg));
  font-size: var(--alert-font-size);
}

.alert > * {
  min-inline-size: 0;
}

/* The alert owns the block spacing between its direct content blocks, the way
   .stack and .prose own theirs, so a title and a description are plain
   siblings instead of a wrapper carrying an inline --gap. What made that pair
   read as detached is the UA paragraph margin: trimming only the outer edges
   left it in place between them. Content keeps its own internal rhythm — a
   nested list keeps its bullets and padding, a .prose child keeps its flow.
   The slots are excluded because they are anatomy, not content: an
   admonition's title bar has to stay flush against its body. The list follows
   the anatomy, so it grows with it. */
.alert > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body)) {
  margin-block: 0;
}

.alert
  > :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body))
  + :not(:where(.alert-icon, .alert-dismiss, .alert-title, .alert-body)) {
  margin-block-start: var(--space-10);
}

/* Alerts are soft by default. The recipe writes local defaults rather than
   --ui-* so an explicit shared treatment always wins without exclusions. */
@supports (color: color-mix(in oklch, red, white)) {
  .alert {
    /* @sync soft-recipe */
    --alert-default-bg: color-mix(
      in oklab,
      var(--surface) var(--soft-bg-mix),
      var(--intent, var(--neutral))
    );
    --alert-default-border: color-mix(
      in oklab,
      var(--surface) var(--soft-border-mix),
      var(--intent, var(--neutral))
    );
    --alert-default-fg: var(
      --intent-soft-fg,
      color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
    );
    /* @sync soft-recipe */
  }
}

/* Decorative leading icon — use .alert-icon on the icon element. */
.alert > .alert-icon {
  display: inline-block;
  flex: none;
  inline-size: var(--alert-icon-size);
  block-size: var(--alert-icon-size);
  font-size: var(--alert-icon-size);
  line-height: 1;
  vertical-align: -0.125em;
}

/* Dismiss — compact close button for inline alerts. Transparent at rest with
   a reduced opacity glyph so it stays secondary inside an already-colored
   alert; hover reinforces it via the shared overlay. Uses the shared
   --icon-close mask, so no icon font or text glyph is required in the markup.
   Standard alerts place it as a direct trailing child; admonitions place it
   inside .alert-title (see below). */
.alert-dismiss {
  --alert-dismiss-size: 1.75rem;
  --alert-dismiss-icon-size: 0.875rem;

  display: inline-grid;
  place-items: center;
  inline-size: var(--alert-dismiss-size);
  block-size: var(--alert-dismiss-size);
  flex: none;
  padding: 0;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: currentColor;
  opacity: 0.65;
  cursor: pointer;
}

.alert-dismiss::before {
  content: "";
  inline-size: var(--alert-dismiss-icon-size);
  block-size: var(--alert-dismiss-icon-size);
  background: currentColor;
  -webkit-mask: var(--icon-close) center / contain no-repeat;
  mask: var(--icon-close) center / contain no-repeat;
}

@media (hover: hover) {
  .alert-dismiss:hover {
    background: var(--hover-overlay);
    opacity: 1;
  }
}

/* Grid anatomy — activated by a leading icon, a trailing dismiss button, or
   both. Icon and dismiss are pinned to the outer columns; anything else (the
   message content) flows into the remaining text column. */
@supports selector(.alert:has(> *)) {
  .alert:has(> .alert-icon, > .alert-dismiss) {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    align-items: var(--items-align, start);
    column-gap: var(--space-30);
  }

  .alert:has(> .alert-icon) {
    grid-template-columns: auto minmax(0, 1fr);
  }

  .alert:has(> .alert-icon) > .alert-icon {
    grid-column: 1;
    grid-row: 1 / -1;
    display: inline-grid;
    place-items: center;
    margin-block-start: 0.125em;
  }

  .alert:has(> .alert-icon) > :not(.alert-icon, .alert-dismiss) {
    grid-column: 2;
  }

  .alert:has(> .alert-dismiss) {
    grid-template-columns: minmax(0, 1fr) auto;
  }

  .alert:has(> .alert-icon):has(> .alert-dismiss) {
    grid-template-columns: auto minmax(0, 1fr) auto;
  }

  .alert:has(> .alert-dismiss) > .alert-dismiss {
    grid-column: -2 / -1;
    grid-row: 1;
  }
}

/* Links */
.alert :where(a:not(.btn)) {
  color: inherit;
  font-weight: var(--font-weight-semibold);
}

/* Forced colors: no override needed. The always-on border gets a forced
   readable color, and text on a Highlight surface would trigger Chromium's
   Canvas backplate (unreadable) — so .solid intentionally falls back to the
   default look. */

/* Callout — leading-edge flag treatment. It owns the border geometry only, so
   the surface and the ink stay with the alert's own intent and shared
   treatment: bare is the soft panel, .surface the page surface, .solid a
   filled one, and .outline has no fill and no other side. Thickness and color
   follow the --alert-border-inline-start-* hooks. */
.alert.callout {
  border: 0;
  border-inline-start: var(--alert-border-inline-start-width, 4px) solid
    var(--alert-border-inline-start-color, var(--intent, var(--neutral)));
}

/* Admonition — structured static box with a tinted title bar and a body
   that rests on the page surface. Not collapsible — use .accordion inside
   the body if the content needs to be expandable. */
.alert.admonition {
  padding: 0;
  display: block;
  overflow: clip;
  border-color: var(--intent, var(--border));
}

.alert.admonition > .alert-title {
  display: flex;
  align-items: center;
  gap: var(--space-20);
  padding-block: var(--alert-pad-block);
  padding-inline: var(--alert-pad-inline);
  border-start-start-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
  border-start-end-radius: calc(var(--alert-radius, var(--radius-lg)) - var(--border-width));
}

/* Dismiss inside an admonition title bar rides the inline end. */
.alert.admonition > .alert-title > .alert-dismiss {
  margin-inline-start: auto;
}

@supports (color: color-mix(in oklch, red, white)) {
  .alert.admonition > .alert-title {
    background: color-mix(
      in oklab,
      var(--surface) var(--soft-bg-mix),
      var(--intent, var(--neutral))
    );
  }
}

@supports not (color: color-mix(in oklch, red, white)) {
  .alert.admonition > .alert-title {
    background: var(--intent, var(--neutral));
    color: var(--intent-fg, var(--neutral-fg));
  }
}

.alert.admonition > .alert-body {
  padding-block: var(--alert-pad-block);
  padding-inline: var(--alert-pad-inline);
  background: var(--surface);
  color: var(--text);
}

.alert.admonition > .alert-body > :first-child {
  margin-block-start: 0;
}

.alert.admonition > .alert-body > :last-child {
  margin-block-end: 0;
}
@media print {
  .alert {
    border: var(--border-width) solid CanvasText;
    break-inside: avoid;
  }
}

/*
 * Actions — a horizontal action-button cluster.
 *
 * Composes with .btn (or any inline element) and feeds layout via the shared
 * --gap token.
 *
 * Internal:
 * --gap (local value for the shared gap token)
 */
.actions {
  --gap: var(--space-20);

  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gap);
  margin: 0;
  padding: 0;
  list-style: none;
}

.actions > li {
  margin: 0;
}


/* Surfaces */
/*
 * Cards provide a neutral raised surface for grouped content.
 *
 * Surface, border, radius, padding, density, and elevation stay local here.
 * Direct header/footer children are structural slots; a direct footer is
 * anchored to the card's bottom edge when extra block space is available (so
 * price/action rows align across equal-height cards in a grid). A bare card
 * owns its direct-child rhythm; a layout primitive on the same element takes
 * over that layout. Page layout remains the job of markup and layout
 * primitives outside the card.
 *
 * Public hooks:
 * --card-pad
 * --card-gap
 * --card-radius
 * --card-max-inline-size
 *
 * Child contract:
 * Direct .bleed children escape --card-pad (negative inset, wider media);
 * edge children inherit the card corners. .bleed must be a direct child.
 *
 * Internal:
 * --card-bg
 * --card-fg
 * --heading
 * --busy-overlay-bg
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity.
   The intent tokens are reset alongside --ui-*: the shared variants
   (.soft/.solid/.outline/.surface) read --intent / --intent-fg / --intent-soft-fg
   when they paint this surface, so an ancestor intent would otherwise leak
   through a variant that has no local intent class. */
:where(.card) {
  --intent: initial;
  --intent-fg: initial;
  --intent-soft-fg: initial;
  --ui-bg: initial;
  --ui-fg: initial;
  --ui-border: initial;
}

.card {
  --card-pad: var(--space-40);
  --card-gap: var(--space-30);
  --card-radius: var(--radius-lg);
  --card-max-inline-size: none;
  --card-bg: var(--ui-bg, var(--surface-raised));
  --card-fg: var(--ui-fg, var(--text));

  /* Keep contextual descendants tied to the surface this card actually owns. */
  --heading: var(--card-fg);
  --busy-overlay-bg: var(--card-bg);

  max-inline-size: var(--card-max-inline-size);
  padding: var(--card-pad);
  background: var(--card-bg);
  color: var(--card-fg);
  border: var(--border-width) solid var(--ui-border, var(--border));
  border-radius: var(--card-radius);
}

/* The box lives at zero specificity so a layout primitive applied to the same
   element owns it. components/ is imported after layout/, so a plain .card
   declaration would beat .media (and any other non-column primitive) on source
   order alone and silently flatten `class="card media"` into a column. The
   column is still the default a bare .card gets, and > footer's margin-auto
   bottom-anchoring depends on it. */
:where(.card) {
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}

/* Native block margins would add to flex gap because flex-item margins never
   collapse. Zero specificity keeps intentional child utilities available. */
:where(.card) > * {
  min-inline-size: 0;
  margin-block: 0;
}

.card > :where(header, footer) {
  display: flex;
  flex-wrap: wrap;
  align-items: var(--items-align, center);
  justify-content: space-between;
  gap: var(--space-30);
  min-inline-size: 0;
}

.card > :where(header, footer) > * {
  min-inline-size: 0;
  margin-block: 0;
}

.card > header {
  align-items: var(--items-align, flex-start);
}

/* A direct footer is pinned to the bottom when the card has free space.
   Specificity beats .stack > * so a .card.stack keeps this contract. */
.card > footer {
  margin-block-start: auto;
}

.card.raised {
  box-shadow: var(--shadow);
}
.card.subtle {
  --card-bg: var(--surface-subtle);
}
.card.compact {
  --card-pad: var(--space-20);
}

/* Full-bleed children */
.card > .bleed {
  margin-inline: calc(var(--card-pad) * -1);
  max-inline-size: none;
}

.card > .bleed:first-child {
  margin-block-start: calc(var(--card-pad) * -1);
  border-start-start-radius: inherit;
  border-start-end-radius: inherit;
}

.card > .bleed:last-child {
  margin-block-end: calc(var(--card-pad) * -1);
  border-end-start-radius: inherit;
  border-end-end-radius: inherit;
}

/* Media bleed */
.card > :where(img, picture, video, canvas, svg).bleed {
  display: block;
  inline-size: calc(100% + var(--card-pad) * 2);
  max-inline-size: none;
}

.card > picture.bleed {
  overflow: hidden;
}

.card > picture.bleed > img {
  display: block;
  inline-size: 100%;
  block-size: auto;
}

/* Padded structural bleed */
.card > :where(header, footer, figure).bleed {
  padding: var(--card-pad);
}
@media print {
  .card {
    border: var(--border-width) solid CanvasText;
    break-inside: avoid;
  }
}

/*
 * Modal shell styling for native dialog elements.
 *
 * Spacing, radius, transitions, and focus treatment come from shared tokens.
 * Opening behavior and intent-specific decisions stay outside this component.
 *
 * Public hooks:
 * --modal-size
 * --dialog-viewport-gap
 * --dialog-icon-size
 * --dialog-icon-glyph-size
 *
 * Internal:
 * --dialog-close-icon-size
 * --dialog-viewport-block
 * --dialog-viewport-gap-default
 * --dialog-viewport-gap-2x
 */
dialog.modal {
  /* --dialog-viewport-gap stays fallback-only for the same reason as
     --modal-size below: declaring the public hook here would give it
     type+class specificity that a single author class cannot outrank. The
     responsive default lives in an internal token instead, so a media query
     can still move it without closing the hook.

     calc() multiplication is not in the supported browser range, so the
     doubled gap is spelled out with an additive calc. */
  --dialog-viewport-gap-default: var(--space-40);
  --dialog-viewport-gap-2x: calc(
    var(--dialog-viewport-gap, var(--dialog-viewport-gap-default)) +
    var(--dialog-viewport-gap, var(--dialog-viewport-gap-default))
  );

  /* Keep the surface anchored to the viewport in the top layer. This also
     provides the containing block for the absolutely positioned close button. */
  position: fixed;

  /* --modal-size stays fallback-only: declaring it on dialog.modal gives it
     type+class specificity, which a single author class cannot outrank, so the
     public hook would silently keep this default. Same reason choice.css never
     declares --choice-control-offset. */
  max-inline-size: min(calc(100% - var(--dialog-viewport-gap-2x)), var(--modal-size, 32rem));

  /* Stable viewport cap for the panel: dvh tracks the dynamic browser toolbar
     and makes the dialog reflow on mobile, svh does not. App shells keep the
     dynamic token; only the modal pins the small viewport. */
  --dialog-viewport-block: var(--viewport-block);
  max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--surface-raised);
  color: var(--text);
  box-shadow: var(--shadow);
  padding: 0;
  overflow: visible;
  opacity: 0;
  /*
   * Keep the open dialog root untransformed. Fixed overlays authored inside a
   * modal dialog use viewport coordinates; transform/translate/scale on this
   * element would make them position against the dialog instead. The closed
   * state can still carry a small transform for enter/exit motion. The lift
   * is a pure vertical offset: scaling a wide panel around its center drags
   * both edges, which reads as horizontal movement.
   */
  transform: translateY(0.35rem);
  transition:
    opacity var(--duration) var(--ease-exit),
    transform var(--duration) var(--ease-exit);
}

dialog.modal > form,
dialog.modal > .stack {
  display: grid;
  gap: var(--space-40);
  max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
  overflow: auto;
  overscroll-behavior: contain;
  padding: var(--space-40);
}

@supports (block-size: 100svh) {
  dialog.modal {
    --dialog-viewport-block: 100svh;
  }
}

/* Keep the dialog off the viewport edges on small screens. Moves the internal
   default, not the public hook, so an author override still wins here too. */
@media (max-width: 30rem) {
  dialog.modal {
    --dialog-viewport-gap-default: var(--space-30);
  }
}

dialog.modal.scrollable > form,
dialog.modal.scrollable > .stack {
  grid-template-rows: auto minmax(0, 1fr) auto;
  overflow: hidden;
}

dialog.modal.scrollable > form > :not(:is(header, footer)),
dialog.modal.scrollable > .stack > :not(:is(header, footer)) {
  min-block-size: 0;
  overflow: auto;
  overscroll-behavior: contain;
}

dialog.modal header > :first-child,
dialog.modal footer > :first-child {
  margin-block-start: 0;
}

dialog.modal header > :last-child,
dialog.modal footer > :last-child {
  margin-block-end: 0;
}

/* The close button is anchored to the dialog at top inline-end, out of the
   content flow; headers reserve inline room so titles never run under it. */
dialog.modal > form > header,
dialog.modal > .stack > header {
  padding-inline-end: calc(var(--control-size) + var(--space-30));
}

dialog.modal footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--gap);
}

/* Confirmation layout — a focused message followed by a visually distinct
   action band. The media primitive owns icon/content alignment; this variant
   only removes the standard form inset so both regions can own their padding. */
dialog.modal.dialog-confirmation > form {
  gap: 0;
  padding: 0;
}

dialog.modal.dialog-confirmation > form > .media {
  gap: var(--space-40);
  padding: var(--space-50);
}

dialog.modal.dialog-confirmation > form > footer {
  padding: var(--space-40) var(--space-50);
  border-end-start-radius: calc(var(--radius-lg) - var(--border-width));
  border-end-end-radius: calc(var(--radius-lg) - var(--border-width));
  background: var(--surface-subtle);
}

/* Intent boundary — inherited intents do not tint the icon well. A local
   intent plus .soft opts into the shared soft-variant recipe. */
:where(.dialog-icon) {
  --intent: initial;
  --intent-fg: initial;
  --intent-soft-fg: initial;
  --ui-bg: initial;
  --ui-fg: initial;
}

.dialog-icon {
  --dialog-icon-size: 3rem;
  --dialog-icon-glyph-size: 1.5rem;

  display: inline-grid;
  place-items: center;
  inline-size: var(--dialog-icon-size);
  block-size: var(--dialog-icon-size);
  flex: none;
  border-radius: var(--radius-full);
  background: var(--ui-bg, var(--surface-subtle));
  color: var(--ui-fg, var(--intent, var(--text-muted)));
}

.dialog-icon > * {
  display: inline-grid;
  place-items: center;
  inline-size: var(--dialog-icon-glyph-size);
  block-size: var(--dialog-icon-glyph-size);
  font-size: var(--dialog-icon-glyph-size);
  line-height: 1;
}

@media (max-width: 30rem) {
  dialog.modal.dialog-confirmation > form > .media,
  dialog.modal.dialog-confirmation > form > footer {
    padding: var(--space-40);
  }
}

dialog.modal[open] {
  opacity: 1;
  transform: none;
  transition:
    opacity var(--duration) var(--ease-enter),
    transform var(--duration) var(--ease-enter);
}

@starting-style {
  dialog.modal[open] {
    opacity: 0;
    transform: translateY(0.35rem);
  }
}

dialog.modal::backdrop {
  background: var(--backdrop-fill);
  opacity: 0;
  /* Scrim curve stays neutral `ease`; only the panel carries the character
     curves. */
  transition: opacity var(--duration) ease;
}

@supports (transition-behavior: allow-discrete) {
  dialog.modal {
    transition:
      opacity var(--duration) var(--ease-exit),
      transform var(--duration) var(--ease-exit),
      overlay var(--duration) allow-discrete,
      display var(--duration) allow-discrete;
  }

  dialog.modal[open] {
    transition:
      opacity var(--duration) var(--ease-enter),
      transform var(--duration) var(--ease-enter),
      overlay var(--duration) allow-discrete,
      display var(--duration) allow-discrete;
  }

  dialog.modal::backdrop {
    transition:
      opacity var(--duration) ease,
      overlay var(--duration) allow-discrete,
      display var(--duration) allow-discrete;
  }
}

dialog.modal[open]::backdrop {
  opacity: 1;
}

html.has-modal-open {
  overflow: hidden;
}

html.has-modal-open.had-scrollbar {
  scrollbar-gutter: stable;
}

@starting-style {
  dialog.modal[open]::backdrop {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: no-preference) {
  /* Wrong-close feedback and view-transition transport are motion quirks, not
     presence enter/exit, so they stay on the plain CSS default easing. */
  dialog.modal.is-static {
    animation: actual-dialog-static var(--duration-slow) ease;
  }

  ::view-transition-group(actual-dialog) {
    animation-duration: var(--duration);
    animation-timing-function: ease;
  }
}

@keyframes actual-dialog-static {
  0%,
  100% {
    transform: none;
  }

  35% {
    transform: translateX(-0.35rem);
  }

  70% {
    transform: translateX(0.35rem);
  }
}

/* Close control. A close button is an action attached to a surface, so it
   never participates in the content flow — absolute at the panel's top
   inline-end, inside the surface. The glyph comes from --icon-close, masked to
   currentColor, so no icon font or text glyph is required in the markup. */
.dialog-close {
  --dialog-close-icon-size: 1rem;

  display: inline-grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: var(--radius);
  color: var(--text-muted);
  cursor: pointer;

  /* Out of flow, anchored to the positioned dialog surface, not the header or
     any scroll container inside it. */
  position: absolute;
  z-index: 1;
  inset-block-start: var(--space-20);
  inset-inline-end: var(--space-20);

  inline-size: var(--control-size);
  block-size: var(--control-size);

  background: var(--surface-subtle);
}

.dialog-close::before {
  content: "";
  inline-size: var(--dialog-close-icon-size);
  block-size: var(--dialog-close-icon-size);
  background: currentColor;
  -webkit-mask: var(--icon-close) center / contain no-repeat;
  mask: var(--icon-close) center / contain no-repeat;
}

@media (hover: hover) {
  .dialog-close:hover {
    box-shadow: inset 0 0 0 999px var(--hover-overlay);
    color: var(--text);
  }
}

.dialog-close:active {
  box-shadow: inset 0 0 0 999px var(--hover-overlay);
}

.dialog-close:focus-visible {
  outline: 2px solid transparent;
  box-shadow: var(--focus-ring-shadow);
}

/*
 * Drawer — modal side-sheet built on the native dialog element.
 *
 * Top-layer rendering, ::backdrop, Escape, and modal focus come from the
 * native dialog. CSS owns progressive enter/exit motion where browsers support
 * discrete display/overlay transitions. The drawer is a layout shell only —
 * navigation content inside uses .nav-link and .accordion, which carry their
 * own link/summary styling.
 *
 * Public hooks:
 * --drawer-size
 * --drawer-pad
 *
 * Internal:
 * --drawer-closed-translate (side/RTL positioning state)
 * --drawer-motion-duration (shared by panel and ::backdrop so exit duration
 *   changes in one place; entry state redefines it longer)
 */
dialog.drawer {
  --drawer-size: 20rem;
  --drawer-pad: var(--space-40);
  --drawer-closed-translate: -100%;
  /* Exit is shorter than entry so the panel clears quickly; the scrim reads
     the same variable through inheritance and never lags the panel. */
  --drawer-motion-duration: var(--duration);

  /* Keep the surface anchored to the viewport in the top layer. This also
     provides the containing block for the absolutely positioned close button.
     A percentage height resolves against the fixed containing viewport, while
     dynamic viewport units can be stale in embedded previews. */
  position: fixed;
  inset-block-start: 0;

  inline-size: min(calc(var(--viewport-inline) * 0.9), var(--drawer-size));
  block-size: 100%;
  max-inline-size: none;
  max-block-size: 100%;
  margin: 0;
  padding: var(--drawer-pad);
  border: 0;
  border-inline-end: var(--border-width) solid var(--border);
  border-radius: 0;
  background: var(--surface-raised);
  color: var(--text);
  box-shadow: var(--shadow);
  opacity: 0;
  transform: translateX(var(--drawer-closed-translate));
  transition:
    opacity var(--drawer-motion-duration) var(--ease-exit),
    transform var(--drawer-motion-duration) var(--ease-exit);
}

dialog.drawer[open] {
  --drawer-motion-duration: var(--duration-slow);
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  gap: var(--space-40);
  opacity: 1;
  transform: translateX(0);
  transition:
    opacity var(--drawer-motion-duration) var(--ease-enter),
    transform var(--drawer-motion-duration) var(--ease-enter);
}

/* A form is the drawer's content wrapper when actions need method="dialog".
   Span the drawer tracks so the form can keep the header at the top, let the
   body absorb the available height, and keep the footer at the bottom. */
dialog.drawer > form {
  display: grid;
  grid-row: 1 / -1;
  grid-template-rows: auto minmax(0, 1fr) auto;
  gap: var(--space-40);
  min-block-size: 100%;
}

/* The shared backdrop token keeps drawer and modal scrims consistent. */
dialog.drawer::backdrop {
  background: var(--backdrop-fill);
  opacity: 0;
  /* Scrim curve stays neutral `ease`; only the panel's character curves
     apply. Duration follows --drawer-motion-duration through inheritance. */
  transition: opacity var(--drawer-motion-duration) ease;
}

dialog.drawer[data-side="end"] {
  --drawer-closed-translate: 100%;

  margin-inline-start: auto;
  margin-inline-end: 0;
  border-inline-end: 0;
  border-inline-start: var(--border-width) solid var(--border);
}

dialog.drawer[open]::backdrop {
  opacity: 1;
}

@starting-style {
  dialog.drawer[open] {
    opacity: 0;
    transform: translateX(var(--drawer-closed-translate));
  }

  dialog.drawer[open]::backdrop {
    opacity: 0;
  }
}

/* Scrollable navigation region inside the drawer. */
dialog.drawer nav {
  min-block-size: 0;
  overflow-y: auto;
}

@supports (transition-behavior: allow-discrete) {
  dialog.drawer {
    transition:
      opacity var(--drawer-motion-duration) var(--ease-exit),
      transform var(--drawer-motion-duration) var(--ease-exit),
      overlay var(--drawer-motion-duration) allow-discrete,
      display var(--drawer-motion-duration) allow-discrete;
  }

  dialog.drawer[open] {
    transition:
      opacity var(--drawer-motion-duration) var(--ease-enter),
      transform var(--drawer-motion-duration) var(--ease-enter),
      overlay var(--drawer-motion-duration) allow-discrete,
      display var(--drawer-motion-duration) allow-discrete;
  }

  dialog.drawer::backdrop {
    transition:
      opacity var(--drawer-motion-duration) ease,
      overlay var(--drawer-motion-duration) allow-discrete,
      display var(--drawer-motion-duration) allow-discrete;
  }
}

/* Safari before 16.4 does not support :dir(); keep the ancestor-attribute
   path as the Degraded-tier fallback. The :dir() rules are dropped on their
   own there, so they need no wrapper. :dir() follows inherited HTML
   directionality and handles nested direction changes; the explicit ltr rules
   neutralize an outer RTL fallback selector. Each specificity group keeps the
   fallback before the :dir() rules. */
[dir="rtl"] dialog.drawer {
  --drawer-closed-translate: 100%;
}

dialog.drawer:dir(ltr) {
  --drawer-closed-translate: -100%;
}

dialog.drawer:dir(rtl) {
  --drawer-closed-translate: 100%;
}

[dir="rtl"] dialog.drawer[data-side="end"] {
  --drawer-closed-translate: -100%;
}

dialog.drawer[data-side="end"]:dir(ltr) {
  --drawer-closed-translate: 100%;
}

dialog.drawer[data-side="end"]:dir(rtl) {
  --drawer-closed-translate: -100%;
}

@media (prefers-reduced-motion: no-preference) {
  dialog.drawer.is-static {
    animation: actual-dialog-static var(--duration-slow) ease;
  }
}

/* The header reserves inline room so the title never runs under the close
   control. */
dialog.drawer > header,
dialog.drawer > form > header {
  padding-inline-end: calc(var(--control-size) + var(--space-30));
}

/* Close control, owned here so a drawer-only build does not depend on
   modal.css. Mirrors the modal's .dialog-close placement: absolute at the
   panel's top inline-end, out of the content flow. */
.drawer-close {
  --drawer-close-icon-size: 1rem;

  display: inline-grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: var(--radius);
  color: var(--text-muted);
  cursor: pointer;

  position: absolute;
  z-index: 1;
  inset-block-start: var(--space-20);
  inset-inline-end: var(--space-20);

  inline-size: var(--control-size);
  block-size: var(--control-size);

  background: var(--surface-subtle);
}

.drawer-close::before {
  content: "";
  inline-size: var(--drawer-close-icon-size);
  block-size: var(--drawer-close-icon-size);
  background: currentColor;
  -webkit-mask: var(--icon-close) center / contain no-repeat;
  mask: var(--icon-close) center / contain no-repeat;
}

@media (hover: hover) {
  .drawer-close:hover {
    box-shadow: inset 0 0 0 999px var(--hover-overlay);
    color: var(--text);
  }
}

.drawer-close:active {
  box-shadow: inset 0 0 0 999px var(--hover-overlay);
}

.drawer-close:focus-visible {
  outline: 2px solid transparent;
  box-shadow: var(--focus-ring-shadow);
}


/* Overlays */
/*
 * Flyout — floating panel for actions and links.
 *
 * JavaScript promotes the panel to the top layer and chooses viewport or
 * document coordinates from its anchor. It also owns state (.is-open), focus,
 * and keyboard behavior. CSS owns the surfaced panel appearance, item styles,
 * and animation.
 *
 * Runtime marker (managed by surface.js): .is-open marks an open flyout.
 * --surface-anchor-width is set by surface.js while an open flyout is positioned.
 *
 * Public hooks:
 * --flyout-inline-size
 * --flyout-max-inline-size
 *
 * Internal:
 * --menu-item-size (derived from --control-size for menu rows)
 * --available-height (measured by the positioner)
 * --surface-anchor-width (measured by surface.js)
 */

.flyout-trigger {
  position: relative;
  display: inline-block;
}

/* Stretch: for a flyout trigger that must span its container — e.g. the
   last row of a full-width sidebar nav list. Stretches both the
   positioning wrapper and its trigger control. Named for the effect
   (stretch to fill), not the display value, to avoid reading like a
   generic display-block utility. */
.flyout-trigger.stretch {
  display: block;
  inline-size: 100%;
}

.flyout-trigger.stretch > [aria-haspopup="menu"] {
  inline-size: 100%;
}

.flyout {
  --menu-item-size: var(--control-size);
  z-index: var(--z-menu);
  /* No-JS fallback: below the trigger inside the relative .flyout-trigger.
     surface.js replaces this with the anchor's coupled position/coordinate
     mode; this only covers a flyout revealed without the runtime. */
  position: absolute;
  inset-block-start: calc(100% + var(--space-10));
  inset-inline-start: 0;
  display: grid;
  gap: var(--space-10);
  inline-size: var(--flyout-inline-size, max-content);
  min-inline-size: min(
    var(--surface-anchor-width, 0px),
    min(var(--flyout-max-inline-size, 20rem), calc(var(--viewport-inline) - var(--space-60)))
  );
  max-inline-size: min(
    var(--flyout-max-inline-size, 20rem),
    calc(var(--viewport-inline) - var(--space-60))
  );
  max-block-size: min(
    24rem,
    var(--available-height, calc(var(--viewport-block) - var(--space-60)))
  );
  overflow: auto;
  margin: 0;
  padding: var(--space-10);
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
  box-shadow: var(--shadow-popout);
  list-style: none;
  opacity: 1;
  /* Flyouts fade in and out; scaling around the center would detach the
     anchored edge from its trigger on wide panels. */
  transition: opacity var(--duration-fast) ease;
}

/* A popover enters the top layer with UA geometry that conflicts with anchored
   positioning. surface.js sets popover="manual" on every surface it manages,
   so this is the transport's own geometry rather than an opt-in. Kept scoped to
   Actual surfaces so bare [popover] elements retain their platform
   presentation. */
.flyout[popover] {
  position: fixed;
  inset: auto;
  margin: 0;
}

/* A presentation class may own its *visible* display type, but must not
   override the platform's hidden state. The UA closes a popover with
   display: none, and an author declaration beats the UA origin whatever the
   specificity — so .flyout's own display: grid would leave a closed popover
   painted at its static position. The opacity is the exit target: with nothing
   to animate, the overlay/display allow-discrete transition below would hold a
   fully opaque panel in the top layer and then snap it away. */
.flyout[popover]:not(:popover-open) {
  display: none;
  opacity: 0;
}

/* Both lifecycles in one rule: .is-open for a surface.js panel, :popover-open
   for one an author drives natively with popovertarget. */
.flyout:is(.is-open, :popover-open) {
  inset: auto;
}

/* @starting-style is above the floor (Safari 17.5), so where it is missing the
   entry simply has no animated origin and the panel appears at full opacity. */
@starting-style {
  .flyout:is(.is-open, :popover-open) {
    opacity: 0;
  }
}

.flyout[hidden] {
  display: none;
  opacity: 0;
}

/* display keeps the exit transition alive for the [hidden] path; overlay does
   the same for the native popover path, where hidePopover() would otherwise
   drop the panel out of the top layer on the first frame and let a parent
   overflow or a dialog clip the fade. An engine that cannot transition either
   property ignores it and the exit is instant. */
@supports (transition-behavior: allow-discrete) {
  .flyout {
    transition:
      opacity var(--duration-fast) ease,
      overlay var(--duration-fast) allow-discrete,
      display var(--duration-fast) allow-discrete;
  }
}

.flyout > li {
  margin: 0;
}

.flyout > :where(section, footer) {
  min-inline-size: 0;
  padding: var(--space-20);
}

.flyout > section > :where(h1, h2, h3, h4, h5, h6) {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-semibold);
}

.flyout > section > ul {
  display: grid;
  gap: var(--space-10);
  margin: var(--space-10) 0 0;
  padding: 0;
  list-style: none;
}

.flyout > section > ul > li {
  margin: 0;
}

/* Vertical dividers — .menu-separator (menu.css) is the canonical way to
   separate menu items. Flyout panels that are not menus use auto grid gap
   declared on .flyout itself. */

/*
 * Menu — action list primitive consumed by flyout and context-menu.
 *
 * Strict anatomy: .menu > li > .menu-item. Separators and section labels
 * sit directly under .menu as siblings of <li>.
 *
 * Public hooks:
 * --menu-item-icon-size
 *
 * Intent: .menu-item consumes --intent as its text color, so intent classes
 * (.danger, .success, …) tint an item without a per-intent variant.
 */

.menu {
  display: grid;
  gap: var(--space-10);
  margin: 0;
  padding: var(--space-10);
  list-style: none;
}

.menu > li {
  margin: 0;
}

/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.menu-item) {
  --intent: initial;
  --intent-fg: initial;
}

.menu-item {
  --menu-item-icon-size: 1.25em;

  display: flex;
  align-items: center;
  gap: var(--space-20);
  inline-size: 100%;
  min-block-size: var(--menu-item-size, var(--control-size));
  padding-inline: var(--space-20);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--intent, var(--text));
  font: inherit;
  font-size: var(--control-font-size);
  text-align: start;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
}

.menu-item-icon {
  display: grid;
  flex: 0 0 var(--menu-item-icon-size);
  inline-size: var(--menu-item-icon-size);
  place-items: center;
}

.menu-item-text {
  flex: 1;
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

.menu-item-end {
  display: inline-flex;
  align-items: center;
  gap: var(--space-10);
  margin-inline-start: auto;
  color: var(--text-muted);
}

/* Checkable menu items are one command with one accessible state. Their
   indicator is generated so authors do not add a second interactive input. */
.menu-item:is([role="menuitemcheckbox"], [role="menuitemradio"])::before {
  content: "";
  display: block;
  flex: 0 0 var(--menu-item-icon-size);
  inline-size: var(--menu-item-icon-size);
  block-size: var(--menu-item-icon-size);
}

.menu-item[role="menuitemcheckbox"][aria-checked="true"]::before {
  background: currentColor;
  -webkit-mask: var(--icon-check) center / contain no-repeat;
  mask: var(--icon-check) center / contain no-repeat;
}

.menu-item[role="menuitemcheckbox"][aria-checked="mixed"]::before {
  background:
    linear-gradient(currentColor 0 0) center / 60% max(var(--border-width), 0.125em) no-repeat;
}

.menu-item[role="menuitemradio"]::before {
  background: radial-gradient(circle, transparent 0 46%, currentColor 48% 58%, transparent 60%);
}

.menu-item[role="menuitemradio"][aria-checked="true"]::before {
  background: radial-gradient(
    circle,
    currentColor 0 25%,
    transparent 27% 46%,
    currentColor 48% 58%,
    transparent 60%
  );
}

.menu-item:focus-visible {
  background: var(--surface-subtle);
}

.menu-item:hover:not(:where(:disabled, [aria-disabled="true"])) {
  background: var(--surface-subtle);
}

.menu-item:is(:disabled, [aria-disabled="true"]) {
  cursor: not-allowed;
  opacity: var(--disabled-opacity);
}

.menu-label {
  padding: var(--space-20);
  color: var(--text-muted);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-semibold);
}

.menu-separator {
  margin: 0;
  border: 0;
  border-block-start: var(--border-width) solid var(--border);
}

/*
 * Tooltip — supplemental label overlaid near a trigger.
 *
 * Position, placement, and arrow position are managed by JavaScript via left,
 * top, data-placement, and --arrow-x. The runtime also owns `position`: it
 * writes `absolute` for a tip whose trigger scrolls with the page, so the
 * browser carries the tip along instead of the positioner correcting it a
 * frame later, and `fixed` — the declaration below — for every other trigger.
 * Visibility is the Popover lifecycle: tooltip.js promotes the tip to the top
 * layer with popover="manual". CSS owns entry and exit animation.
 *
 * Public hooks:
 * --tooltip-bg
 * --tooltip-fg
 * --tooltip-arrow-size
 *
 * Internal:
 * --arrow-x (arrow position, written by the positioner)
 * --arrow-y (arrow position, written by the positioner)
 */
.tooltip {
  --tooltip-bg: var(--surface-solid);
  --tooltip-fg: var(--surface);
  --tooltip-arrow-size: 5px;

  position: fixed;
  max-inline-size: min(24rem, calc(var(--viewport-inline) - 2rem));
  padding: 0.25em 0.6em;
  border-radius: var(--radius-sm);
  border: var(--border-width) solid transparent;
  background: var(--tooltip-bg);
  color: var(--tooltip-fg);
  font-size: var(--font-size-xs);
  line-height: var(--line-height-tight);
  overflow-wrap: anywhere;
  pointer-events: auto;
  user-select: none;
  /* Tooltips fade only: the anchor edge, marked by the arrow, must stay put
     during motion, and a 4% scale was perceptible on small text. Scaling moves
     the arrow tip away from its trigger on every placement. */
  opacity: 1;
  transition: opacity var(--duration-fast) ease;
}

/* Neutralize only the native geometry that conflicts with a positioned
   tooltip. In particular, the UA overflow would turn the arrow into scroll
   overflow instead of letting it project beyond the bubble.

   One rule for both lifecycles: the runtime writes popover="manual" on the
   tips it manages, and an application driving a .tooltip[popover] itself lands
   on the same declarations. Who owns which is in
   docs/pages/enhancements/overview.md. */
.tooltip[popover] {
  inset: auto;
  margin: 0;
  overflow: visible;
}

/* The closed state, for either lifecycle. .tooltip declares no display of its
   own so the UA would already hide it, but the opacity target is what makes
   the exit fade at all — see the overlay note below. */
.tooltip[popover]:not(:popover-open) {
  display: none;
  opacity: 0;
}

/* The state before enhancement, and the author's to write — not the runtime's.
   Wiring is lazy: tooltip.js adopts an explicit tip on its trigger's first
   hover, so a tooltip nobody hovers keeps [hidden] for the whole life of the
   page. Without this rule it would sit on screen at full opacity until then.
   The runtime removes the attribute at the moment it takes the lifecycle over,
   because an author declaration outranks the platform's own hidden state. */
.tooltip[hidden] {
  display: none;
  opacity: 0;
}

/* Entry opacity, for either lifecycle — both are :popover-open.
   @starting-style is above the floor (Safari 17.5); where it is missing the
   tip simply appears at full opacity. */
@starting-style {
  .tooltip:popover-open {
    opacity: 0;
  }
}

/* overlay is the native popover counterpart of display: it holds the tip in
   the top layer until the fade finishes, instead of letting hidePopover()
   demote it on the first frame. Ignored where unsupported. */
@supports (transition-behavior: allow-discrete) {
  .tooltip {
    transition:
      opacity var(--duration-fast) ease,
      overlay var(--duration-fast) allow-discrete,
      display var(--duration-fast) allow-discrete;
  }
}

/* ── Arrow — clipped triangle ──────────────────────── */

.tooltip::before {
  content: "";
  position: absolute;
  inline-size: calc(var(--tooltip-arrow-size) * 2);
  block-size: var(--tooltip-arrow-size);
  background: var(--tooltip-bg);
}

.tooltip[data-placement^="top"]::before {
  bottom: calc(-0.98 * var(--tooltip-arrow-size));
  left: var(--arrow-x, 50%);
  translate: -50% 0;
  clip-path: polygon(50% 100%, 0 0, 100% 0);
}

.tooltip[data-placement^="bottom"]::before {
  top: calc(-0.98 * var(--tooltip-arrow-size));
  left: var(--arrow-x, 50%);
  translate: -50% 0;
  clip-path: polygon(50% 0, 0 100%, 100% 100%);
}

.tooltip[data-placement^="left"]::before {
  right: calc(-0.98 * var(--tooltip-arrow-size));
  top: var(--arrow-y, 50%);
  inline-size: var(--tooltip-arrow-size);
  block-size: calc(var(--tooltip-arrow-size) * 2);
  translate: 0 -50%;
  clip-path: polygon(100% 50%, 0 0, 0 100%);
}

.tooltip[data-placement^="right"]::before {
  left: calc(-0.98 * var(--tooltip-arrow-size));
  top: var(--arrow-y, 50%);
  inline-size: var(--tooltip-arrow-size);
  block-size: calc(var(--tooltip-arrow-size) * 2);
  translate: 0 -50%;
  clip-path: polygon(0 50%, 100% 0, 100% 100%);
}


/* Disclosure */
/*
 * Accordion disclosures built on native details and summary.
 *
 * Keeps the clickable summary affordance aligned and usable without JavaScript
 * or demo styles. Decorative palettes and custom markers belong outside the
 * base component.
 *
 * Container treatments (structural chrome, not skins):
 * - default — the common raised shell;
 * - .flush — the shell removed, only the item separators remain, so the group
 *   can sit directly inside an existing surface (card, drawer, page section);
 * - .separated — each item becomes its own card.
 *
 * Public hooks:
 * --accordion-radius
 * --accordion-marker-color
 */
.accordion {
  --accordion-radius: var(--radius);
  --accordion-marker-color: var(--text-muted);

  border: var(--border-width) solid var(--border);
  border-radius: var(--accordion-radius);
  background: var(--surface-raised);
}

.accordion > details + details {
  border-block-start: var(--border-width) solid var(--border);
}

.accordion summary {
  display: flex;
  align-items: center;
  gap: var(--space-30);
  cursor: pointer;
  list-style: none;
  padding: var(--space-40);
  color: var(--text);
  font-weight: var(--font-weight-medium);
}

.accordion summary:hover::after {
  /* The whole row is one focus target; reinforcing only the end marker keeps
     the affordance on the disclosure control instead of making the whole
     label read as a link. */
  --accordion-marker-color: currentColor;
}

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

.accordion summary::after {
  content: "";
  flex: 0 0 auto;
  margin-inline-start: auto;
  inline-size: 1rem;
  block-size: 1rem;
  background-color: var(--accordion-marker-color);
  -webkit-mask: var(--icon-chevron) center / contain no-repeat;
  mask: var(--icon-chevron) center / contain no-repeat;
  transition: transform var(--duration);
}

.accordion details[open] summary::after {
  transform: rotate(180deg);
}

.accordion details > summary + * {
  padding-block: 0 var(--space-40);
  padding-inline: var(--space-40);
  color: var(--text-muted);
  margin-block: 0;
}

.accordion details[open] > summary + * {
  padding-block-start: var(--space-10);
}

.accordion details > summary + * > :first-child {
  margin-block-start: 0;
}

.accordion details > summary + ul,
.accordion details > summary + ol {
  margin: 0;
  padding-inline-start: 2rem;
}

/* Flush — the shell is dropped and only the shared item separators remain.
   Zero inline padding puts the group edge to edge inside the surface that
   already frames it, so no override is repeated per embedding. */
.accordion.flush {
  border: 0;
  border-radius: 0;
  background: transparent;
}

.accordion.flush summary,
.accordion.flush details > summary + * {
  padding-inline: 0;
}

/* Separated — each item owns a full card. The item's own border beats the
   shared details + details separator on specificity alone, so nothing needs
   undoing for the second item onwards. */
.accordion.separated {
  display: grid;
  gap: var(--space-20);
  border: 0;
  background: transparent;
}

.accordion.separated > details {
  border: var(--border-width) solid var(--border);
  border-radius: var(--accordion-radius);
  background: var(--surface-raised);
}

@media (forced-colors: active) {
  .accordion {
    --accordion-marker-color: CanvasText;
  }
}


/* Feedback */
/*
 * Spinner — compact loading feedback.
 *
 * Uses currentColor so it adapts to buttons, alerts, badges, and text. The
 * [aria-busy] overlay composition lives in components/busy.css.
 *
 * Public hooks:
 * --spinner-size
 * --spinner-width
 * --spinner-track
 * --spinner-value
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.spinner) {
  --intent: initial;
}

.spinner {
  --spinner-size: 1em;
  --spinner-width: 0.125em;
  --spinner-track: currentColor;
  --spinner-value: transparent;

  color: var(--intent, currentColor);
  display: inline-block;
  flex-shrink: 0;
  inline-size: var(--spinner-size);
  block-size: var(--spinner-size);
  border: var(--spinner-width) solid var(--spinner-track);
  border-inline-end-color: var(--spinner-value);
  border-radius: var(--radius-full);
  opacity: 0.85;
  vertical-align: text-bottom;
}

@supports (color: color-mix(in oklch, red, white)) {
  .spinner {
    --spinner-track: color-mix(in oklch, currentColor, transparent 70%);
    --spinner-value: currentColor;
    opacity: 1;
  }
}

.spinner.sm {
  --spinner-size: 0.75em;
}

.spinner.lg {
  --spinner-size: 2rem;
  --spinner-width: 3px;
}

@media (prefers-reduced-motion: no-preference) {
  .spinner {
    animation: actual-spin var(--duration-spin) linear infinite;
  }
}

@keyframes actual-spin {
  to {
    transform: rotate(360deg);
  }
}

/*
 * Skeleton placeholders for loading states.
 *
 * Shows approximate loading structure without implying real content hierarchy.
 * Geometry, animation, and color stay theme-aware.
 *
 * Public hooks:
 * --skeleton-size
 * --skeleton-width
 * --skeleton-radius
 * --skeleton-track
 * --skeleton-highlight
 */
.skeleton {
  --skeleton-size: 1rem;
  --skeleton-width: 100%;
  --skeleton-radius: var(--radius);
  --skeleton-track: var(--surface-subtle);
  --skeleton-highlight: var(--surface-raised);

  display: block;
  inline-size: var(--skeleton-width);
  max-inline-size: 100%;
  block-size: var(--skeleton-size);
  border-radius: var(--skeleton-radius);
  background:
    linear-gradient(90deg, var(--skeleton-track), var(--skeleton-highlight), var(--skeleton-track))
    0 0 / 200% 100%;
}

@media (prefers-reduced-motion: no-preference) {
  .skeleton {
    animation: actual-skeleton var(--duration-shimmer) ease-in-out infinite;
  }
}

.skeleton[data-shape="text"] {
  --skeleton-size: 0.85rem;
  --skeleton-width: 32rem;
}

.skeleton[data-shape="title"] {
  --skeleton-size: 1.4rem;
  --skeleton-width: 18rem;
}

.skeleton[data-shape="avatar"] {
  --skeleton-size: var(--avatar-size, 2.25rem);
  --skeleton-width: var(--skeleton-size);
  --skeleton-radius: var(--radius-full);

  flex: 0 0 auto;
}

.skeleton[data-shape="box"] {
  --skeleton-size: 8rem;
}

@media (forced-colors: active) {
  .skeleton {
    border: var(--border-width) solid ButtonText;
    background: GrayText;
  }
}

@keyframes actual-skeleton {
  from {
    background-position: 200% 0;
  }

  to {
    background-position: -200% 0;
  }
}

/*
 * Busy overlay — non-button loading state.
 *
 * A non-button element with aria-busy="true" and a direct last-child .spinner
 * shows that spinner as a centered overlay over a faded surface. Buttons keep
 * their spinners inline (e.g. .btn[aria-busy] > .spinner stays inline-flex).
 *
 * Public hooks:
 * --busy-overlay-bg
 *
 * Internal:
 * --spinner-size (overlay copy of the shared spinner hook)
 */
[aria-busy] > .spinner:last-child {
  display: none;
}

[aria-busy="true"] > .spinner:last-child {
  display: inline-block;
}

@supports selector([aria-busy="true"]:has(> .spinner:last-child)) {
  [aria-busy="true"]:not(.btn):has(> .spinner:last-child) {
    position: relative;
  }

  [aria-busy="true"]:not(.btn):has(> .spinner:last-child)::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
    border-radius: inherit;
    background: var(--busy-overlay-bg, var(--surface));
  }

  [aria-busy="true"]:not(.btn) > .spinner:last-child {
    --spinner-size: 2rem;
    position: absolute;
    inset: 0;
    z-index: 2;
    margin: auto;
    display: block;
  }
}

@supports (selector([aria-busy="true"]:has(> .spinner:last-child))) and
  (color: color-mix(in oklch, red, white)) {
  [aria-busy="true"]:not(.btn):has(> .spinner:last-child)::before {
    background: color-mix(in oklch, var(--busy-overlay-bg, var(--surface)), transparent 20%);
  }
}

/*
 * Progress — native progress bar with theme-aware track and value.
 *
 * Consumes the shared --intent/--primary for the value bar and --bar-height
 * for thickness. Indeterminate state animates a shimmer using --duration-shimmer.
 *
 * Thickness is a dimension, not a density step: the bar always spans its
 * container and only gets thinner or thicker, so --bar-height is the API and
 * .sm/.lg are deliberately not supported.
 *
 * Public hooks:
 * --progress-track
 * --progress-value
 * --bar-height
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.progress) {
  --intent: initial;
}

.progress {
  --progress-track: var(--surface-subtle);
  --progress-value: var(--intent, var(--primary));

  appearance: none;
  -webkit-appearance: none;
  display: block;
  inline-size: 100%;
  block-size: var(--bar-height);
  overflow: hidden;
  border: 0;
  border-radius: var(--radius-full);
  background: var(--progress-track);
}

.progress::-webkit-progress-bar {
  block-size: var(--bar-height);
  border-radius: var(--radius-full);
  background: var(--progress-track);
}

.progress::-webkit-progress-value {
  block-size: var(--bar-height);
  border-radius: var(--radius-full);
  background: var(--progress-value);
}

.progress::-moz-progress-bar {
  block-size: var(--bar-height);
  border-radius: var(--radius-full);
  background: var(--progress-value);
}

/* Indeterminate progress */
.progress:indeterminate {
  background:
    linear-gradient(to right, var(--progress-value) 30%, var(--progress-track) 30%) top left / 150%
    150% no-repeat,
    var(--progress-track);
}

.progress:indeterminate::-webkit-progress-bar {
  background: transparent;
}

.progress:indeterminate::-webkit-progress-value {
  background: transparent;
}

.progress:indeterminate::-moz-progress-bar {
  background: transparent;
}

@media (prefers-reduced-motion: no-preference) {
  .progress:indeterminate {
    animation: actual-progress-indeterminate var(--duration-shimmer) ease-in-out infinite;
  }

  :where([dir="rtl"] .progress:indeterminate) {
    animation-direction: reverse;
  }
}

@keyframes actual-progress-indeterminate {
  0% {
    background-position: 200% 0;
  }

  20% {
    background-position: 100% 0;
  }

  80% {
    background-position: -100% 0;
  }

  100% {
    background-position: -200% 0;
  }
}

@media (forced-colors: active) {
  .progress::-webkit-progress-value,
  .progress:indeterminate::-webkit-progress-value {
    background: Highlight;
  }

  .progress::-moz-progress-bar,
  .progress:indeterminate::-moz-progress-bar {
    background: Highlight;
  }
}

/*
 * Status bar — singleton floating status area.
 *
 * One element in the HTML, empty by default. .is-open is the visual state, set
 * by status.js like any other surface: it lands after the message is written
 * and lifts before the bar is cleared, so the bar leaves in the exact state it
 * was shown in — same text, same width, same intent color — instead of snapping
 * to a neutral empty pill mid-exit. Emptying the text is the *end* of the exit,
 * not its trigger.
 *
 * Consumes intent like badge or alert. No need for status-bar-specific modifier
 * classes — .danger / .success / .warning / .neutral from intents.css drive
 * the coloring via --intent / --intent-fg. status.js applies these as plain
 * classes on the target; it does not know their names or that they exist.
 *
 * CSS API:            .status-bar
 * JS target contract:  [data-status][role="status"]
 * Normally combined, but they describe separate concerns.
 *
 * Internal:
 * --status-bg
 * --status-fg
 * --status-viewport-offset (written by status.js while the bar is open)
 */

/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.status-bar) {
  --intent: initial;
  --intent-fg: initial;
}

.status-bar {
  --status-bg: var(--intent, var(--text));
  --status-fg: var(--intent-fg, var(--surface));

  position: fixed;
  /* Fixed positioning tracks the layout viewport, which the mobile software
     keyboard covers. status.js writes the occluded band into the offset. */
  inset-block-end: calc(var(--space-40) + var(--status-viewport-offset, 0px));
  inset-inline: var(--space-40);
  z-index: var(--z-status);

  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-20);

  inline-size: fit-content;
  max-inline-size: min(calc(var(--viewport-inline) - var(--space-60)), 32rem);
  margin-inline: auto;
  overflow-wrap: anywhere;

  padding-block: var(--space-20);
  padding-inline: var(--space-30);

  border-radius: var(--radius-full);
  background: var(--status-bg);
  color: var(--status-fg);
  box-shadow: var(--shadow-popout);

  /* Closed by default. Opacity, not visibility or display: the bar has to stay
     in the accessibility tree to work as a live region. */
  opacity: 0;
  translate: 0 var(--space-30);
  pointer-events: none;

  /* Exit is shorter than entry so dismissed feedback gets out of the way.
     background stays a shorthand so --status-bg still accepts gradients;
     solid colors expose an interpolable computed background-color. The
     intent color follows the state/interaction `ease`, not the presence
     curves. */
  transition:
    opacity var(--duration-fast) var(--ease-exit),
    translate var(--duration) var(--ease-exit),
    background-color var(--duration) ease;
}

.status-bar.is-open {
  opacity: 1;
  translate: 0 0;
  /* Entry takes a little longer and decelerates, making the message legible
     before its motion settles. */
  transition:
    opacity var(--duration) var(--ease-enter),
    translate var(--duration-slow) var(--ease-enter),
    background-color var(--duration) ease;
}


/* Display */
/*
 * Avatars for initials, images, links, and buttons.
 *
 * The visual box stays the same across semantic elements. Grouping and status
 * dots (an empty .badge child) compose around the avatar without changing its
 * meaning.
 *
 * Public hooks:
 * --avatar-size
 * --avatar-radius
 * --avatar-font-size
 * --avatar-stack-size
 * --avatar-stack-font-size
 * --avatar-stack-overlap
 * --avatar-stack-ring
 *
 * Internal:
 * --avatar-bg
 * --avatar-fg
 * --avatar-status-translate-x
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.avatar) {
  --intent: initial;
  --intent-fg: initial;
}

.avatar {
  --avatar-size: var(--avatar-stack-size, 2.25rem);
  --avatar-radius: var(--radius-full);
  --avatar-font-size: var(--avatar-stack-font-size, var(--font-size-sm));
  --avatar-bg: var(--intent, var(--surface-subtle));
  --avatar-fg: var(--intent-fg, var(--tone-fg, var(--text-muted)));

  position: relative;
  display: inline-grid;
  place-items: center;
  inline-size: var(--avatar-size);
  block-size: var(--avatar-size);
  overflow: visible;
  padding: 0;
  border: 0;
  border-radius: var(--avatar-radius);
  background: var(--avatar-bg);
  color: var(--avatar-fg);
  font: inherit;
  font-size: var(--avatar-font-size);
  font-weight: var(--font-weight-semibold);
  line-height: 1;
  text-decoration: none;
  vertical-align: middle;
  user-select: none;
  transition:
    box-shadow var(--duration),
    opacity var(--duration),
    transform var(--duration);
}

/* Forced colors wipe the background and the stack ring (box-shadow), so the
   shape needs a real border to stay visible. */
@media (forced-colors: active) {
  .avatar {
    border: var(--border-width) solid CanvasText;
  }

  :where(a.avatar, button.avatar)::after {
    display: none;
  }
}

/* The initials element (a span or an accessible <abbr title="…">) fills the box.
   text-decoration: none neutralizes the UA dotted underline on <abbr title="…">
   inside the circular avatar. */
.avatar > :first-child:not(:where(img, picture, .badge)) {
  display: inline-grid;
  place-items: center;
  inline-size: 100%;
  block-size: 100%;
  overflow: hidden;
  border-radius: inherit;
  text-decoration: none;
}

.avatar > img,
.avatar > picture {
  display: block;
  inline-size: 100%;
  block-size: 100%;
  overflow: hidden;
  border-radius: inherit;
}

.avatar > img,
.avatar > picture > img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  border-radius: inherit;
}

.avatar > picture > img {
  display: block;
}

button.avatar {
  appearance: none;
  cursor: pointer;
}

:where(a.avatar, button.avatar)::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--hover-overlay);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--duration);
}

:where(a.avatar, button.avatar):not(:where(:disabled, [aria-disabled="true"])):hover::after {
  opacity: 1;
}

:where(a.avatar, button.avatar):not(:where(:disabled, [aria-disabled="true"])):active {
  transform: translateY(1px);
}

button.avatar:disabled,
a.avatar[aria-disabled="true"] {
  cursor: not-allowed;
  opacity: var(--disabled-opacity);
}

a.avatar[aria-disabled="true"] {
  pointer-events: none;
}

.avatar.sm {
  --avatar-stack-size: 1.75rem;
  --avatar-stack-font-size: var(--font-size-xs);
}

.avatar.lg {
  --avatar-stack-size: 3rem;
  --avatar-stack-font-size: var(--font-size-lg);
}

/* Status dot: an empty .badge positioned at the avatar's bottom-end corner.
   The dot conveys no text — always pair it with an aria-label on the badge. */
.avatar > .badge:empty {
  --avatar-status-translate-x: 50%;

  position: absolute;
  inset-block-end: var(--indicator-offset);
  inset-inline-end: var(--indicator-offset);
  z-index: 1;
  box-shadow: 0 0 0 2px var(--indicator-ring);
  transform: translate(var(--avatar-status-translate-x), 50%);
}

/* Safari before 16.4 has no :dir(); the ancestor-attribute path is the
   Degraded-tier fallback and the :dir() rules are dropped on their own there.
   :dir() follows inherited directionality, so the explicit ltr rule
   neutralizes an outer RTL fallback for a nested direction change. */
[dir="rtl"] .avatar > .badge:empty {
  --avatar-status-translate-x: -50%;
}

.avatar:dir(ltr) > .badge:empty {
  --avatar-status-translate-x: 50%;
}

.avatar:dir(rtl) > .badge:empty {
  --avatar-status-translate-x: -50%;
}

/* Avatar stack — explicit class for overlapping avatar groups.
   Does not rely on role="group" auto-detection. */
.avatar-stack {
  --avatar-stack-overlap: calc(var(--avatar-stack-size, 2.25rem) / -4);
  --avatar-stack-ring: 2px;

  display: inline-flex;
  align-items: center;
}

.avatar-stack.sm {
  --avatar-stack-size: 1.75rem;
  --avatar-stack-font-size: var(--font-size-xs);
}

.avatar-stack.lg {
  --avatar-stack-size: 3rem;
  --avatar-stack-font-size: var(--font-size-lg);
}

@supports selector(:has(> .avatar)) {
  .avatar-stack:has(> .avatar.sm) {
    --avatar-stack-size: 1.75rem;
    --avatar-stack-font-size: var(--font-size-xs);
  }

  .avatar-stack:has(> .avatar.lg) {
    --avatar-stack-size: 3rem;
    --avatar-stack-font-size: var(--font-size-lg);
  }
}

.avatar-stack > .avatar {
  box-shadow: 0 0 0 var(--avatar-stack-ring) var(--surface);
}

.avatar-stack > .avatar + .avatar {
  margin-inline-start: var(--avatar-stack-overlap);
}

.avatar-stack > .avatar:is(:hover, :focus-visible) {
  z-index: 1;
}

/*
 * Indicator — generic corner positioning for overlaid component content.
 *
 * The container only establishes the containing block. The item owns no
 * paint, size, or ring so badges and other components keep their own visual
 * contract.
 *
 * Internal:
 * --indicator-translate-x
 * --indicator-translate-y
 */
.indicator {
  position: relative;
}

.indicator-item {
  --indicator-translate-x: 50%;
  --indicator-translate-y: -50%;

  position: absolute;
  inset-block-start: 0;
  inset-inline-end: 0;
  z-index: 1;
  transform: translate(var(--indicator-translate-x), var(--indicator-translate-y));
}

.indicator-item.start {
  --indicator-translate-x: -50%;

  inset-inline-start: 0;
  inset-inline-end: auto;
}

.indicator-item.bottom {
  --indicator-translate-y: 50%;

  inset-block-start: auto;
  inset-block-end: 0;
}

/* Safari before 16.4 has no :dir(); the ancestor-attribute path is the
   Degraded-tier fallback and the :dir() rules are dropped on their own there.
   :dir() follows inherited directionality, so the explicit ltr rules
   neutralize an outer RTL fallback for a nested direction change. The rules
   stay grouped by specificity so the fallback never follows the :dir() rules. */
[dir="rtl"] .indicator-item {
  --indicator-translate-x: -50%;
}

.indicator-item:dir(ltr) {
  --indicator-translate-x: 50%;
}

.indicator-item:dir(rtl) {
  --indicator-translate-x: -50%;
}

[dir="rtl"] .indicator-item.start {
  --indicator-translate-x: 50%;
}

.indicator-item.start:dir(ltr) {
  --indicator-translate-x: -50%;
}

.indicator-item.start:dir(rtl) {
  --indicator-translate-x: 50%;
}

/*
 * Key — keyboard keys and shortcut tokens for UI chrome.
 *
 * Use .key outside prose. Plain kbd inside .prose gets the same visual
 * treatment from prose.css so authored content stays convenient.
 *
 * Visual twin of .prose kbd (prose.css) — keep the two recipes in sync.
 */
/* @sync key-recipe */
.key {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-inline-size: 1.75em;
  min-block-size: 1.6em;
  padding-inline: 0.45em;
  border: var(--border-width) solid var(--border);
  border-block-end-width: calc(var(--border-width) * 2);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.875em;
  font-weight: var(--font-weight-medium);
  line-height: 1;
  white-space: nowrap;
  vertical-align: 0.08em;
}
/* @sync key-recipe */

/* .key has a compact typographic default of its own, so its local size roles
   step around that default instead of inheriting the generic mapping. */
.key.sm {
  font-size: var(--font-size-xs);
}

.key.lg {
  font-size: var(--font-size-md);
}

/*
 * Message rows composed from an optional avatar, metadata, and bubble.
 *
 * Public hooks:
 * --chat-gap
 * --chat-max-size
 *
 * Internal:
 * --chat-bubble-bg
 * --chat-bubble-fg
 * --chat-bubble-border
 */
.chat {
  --chat-gap: var(--space-30);
  --chat-max-size: min(85%, 40rem);

  display: grid;
  grid-auto-rows: min-content;
  column-gap: var(--chat-gap);
}

.chat-start {
  grid-template-columns: auto minmax(0, 1fr);
  justify-items: start;
}

.chat-end {
  grid-template-columns: minmax(0, 1fr) auto;
  justify-items: end;
}

.chat-avatar {
  grid-row: 1 / span 3;
  align-self: end;
}

.chat-start > .chat-avatar {
  grid-column: 1;
}

.chat-end > .chat-avatar {
  grid-column: 2;
}

.chat-header,
.chat-footer {
  display: flex;
  gap: var(--space-10);
  color: var(--text-muted);
  font-size: var(--font-size-xs);
}

.chat-start > :is(.chat-header, .chat-bubble, .chat-footer) {
  grid-column: 2;
}

.chat-end > :is(.chat-header, .chat-bubble, .chat-footer) {
  grid-column: 1;
}

/* Intent boundary — an outer intent does not tint nested messages. */
:where(.chat-bubble) {
  --intent: initial;
  --intent-fg: initial;
  --intent-soft-fg: initial;
  --ui-bg: initial;
  --ui-fg: initial;
  --ui-border: initial;
}

.chat-bubble {
  --chat-bubble-bg: var(--ui-bg, var(--intent, var(--surface-subtle)));
  --chat-bubble-fg: var(--ui-fg, var(--intent-fg, var(--text)));
  --chat-bubble-border: var(--ui-border, transparent);

  inline-size: fit-content;
  max-inline-size: var(--chat-max-size);
  padding: var(--space-20) var(--space-30);
  border: var(--border-width) solid var(--chat-bubble-border);
  border-radius: var(--radius-lg);
  background: var(--chat-bubble-bg);
  color: var(--chat-bubble-fg);
  overflow-wrap: anywhere;
}

.chat-start > .chat-bubble {
  border-end-start-radius: var(--radius-sm);
}

.chat-end > .chat-bubble {
  border-end-end-radius: var(--radius-sm);
}

/* Remove the reserved avatar gutter when a message has no avatar. This is a
   cosmetic enhancement; the two-column baseline remains fully readable. */
@supports selector(:has(*)) {
  .chat:not(:has(> .chat-avatar)) {
    column-gap: 0;
  }
}

/*
 * Fixed floating action placement and a native-details speed dial.
 * Button owns the trigger's size, shape, and intent; FAB owns placement and
 * the speed dial's structural open/close markers (--icon-plus / --icon-close).
 *
 * Public hooks:
 * --fab-offset
 * --fab-gap
 * --fab-icon-size
 */
.fab {
  --fab-offset: var(--space-40);
  --fab-gap: var(--space-20);
  --fab-icon-size: 1.5rem;

  position: fixed;
  z-index: var(--z-menu);
  inset-block-end: max(var(--fab-offset), env(safe-area-inset-bottom));
  inset-inline-end: var(--fab-offset);
}

.fab > summary {
  /* Centers the toggle markers: the summary is the markers' positioned
     context, not just the app-button class. */
  position: relative;
  list-style: none;
  cursor: pointer;
}

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

/* The speed dial trigger is an icon-only toggle. FAB owns both structural
   markers: masked pseudo-elements swap the shared plus (closed) and close
   (open) glyphs at the same size, so the two states never drift apart. */
.fab > summary::before,
.fab > summary::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  inline-size: var(--fab-icon-size);
  block-size: var(--fab-icon-size);
  background: currentColor;
  opacity: 0;
  transform: scale(0.8);
  transition:
    opacity var(--duration),
    transform var(--duration);
}

.fab > summary::before {
  -webkit-mask: var(--icon-plus) center / contain no-repeat;
  mask: var(--icon-plus) center / contain no-repeat;
  opacity: 1;
  transform: none;
}

.fab > summary::after {
  -webkit-mask: var(--icon-close) center / contain no-repeat;
  mask: var(--icon-close) center / contain no-repeat;
}

.fab-actions {
  position: absolute;
  inset-inline-end: 0;
  inset-block-end: calc(100% + var(--fab-gap));
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--fab-gap);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transform: translateY(var(--space-10));
  transition:
    opacity var(--duration),
    transform var(--duration),
    visibility 0s linear var(--duration);
}

/* One action is one compact row. Keeping this relationship inside FAB avoids
   wrapping and prevents labels from becoming visually associated with the
   preceding or following button. */
.fab-action {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--fab-gap);
  inline-size: max-content;
  max-inline-size: calc(var(--viewport-inline) - var(--fab-offset) - var(--fab-offset));
}

.fab-label {
  flex: 0 1 auto;
  overflow: hidden;
  max-inline-size: 16rem;
  padding: var(--space-10) var(--space-20);
  border-radius: var(--radius);
  border: var(--border-width) solid transparent;
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-medium);
  line-height: var(--line-height-tight);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.fab[open] > .fab-actions {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transform: translateY(0);
  transition-delay: 0s;
}

.fab[open] > summary::before {
  opacity: 0;
  transform: scale(0.8);
}

.fab[open] > summary::after {
  opacity: 1;
  transform: none;
}

/*
 * Rating — integer star rating drawn on native radio inputs.
 *
 * The checked radio remains the functional baseline. :has() progressively
 * fills preceding stars without reversing DOM or keyboard order. Forced
 * colors restores the platform radio presentation.
 *
 * Public hooks:
 * --rating-size
 * --rating-gap
 * --rating-color
 * --rating-empty
 * --rating-star
 */
:where(.rating) {
  --intent: initial;
}

.rating {
  --rating-size: 1.25rem;
  --rating-gap: 0.1rem;
  --rating-color: var(--intent, var(--warning));
  --rating-empty: var(--border);
  --rating-star: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='2 2 16 16'%3E%3Cpath d='M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 0 0 .95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 0 0-.364 1.118l1.07 3.292c.3.922-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 0 0-1.175 0l-2.8 2.034c-.783.57-1.838-.196-1.539-1.118l1.07-3.292a1 1 0 0 0-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 0 0 .951-.69l1.07-3.292Z'/%3E%3C/svg%3E");

  display: inline-flex;
  gap: 0;
  color: var(--rating-color);
}

.rating > input[type="radio"] {
  appearance: none;
  flex: 0 0 auto;
  inline-size: calc(var(--rating-size) + var(--rating-gap));
  block-size: var(--rating-size);
  margin: 0;
  border: 0;
  background: var(--rating-empty);
  /* Contiguous radio boxes own hit-testing; the centered mask preserves the
     star's proportions while leaving a purely visual gap between values. */
  -webkit-mask: var(--rating-star) center / var(--rating-size) var(--rating-size) no-repeat;
  mask: var(--rating-star) center / var(--rating-size) var(--rating-size) no-repeat;
  cursor: pointer;
  transition: background-color var(--duration);
}

.rating > input[type="radio"]:checked {
  background: var(--rating-color);
}

.rating > input[type="radio"]:disabled {
  cursor: not-allowed;
  opacity: var(--disabled-opacity);
}

.rating > input[type="radio"]:focus-visible {
  outline: var(--focus-outline);
  outline-offset: var(--focus-outline-offset);
  background: var(--rating-color);
}

/* Browsers without :has() still show the selected native radio as one filled
   star. The enhancement only adds cumulative checked and hover previews. */
@supports selector(:has(*)) {
  .rating > input[type="radio"]:has(~ input[type="radio"]:checked) {
    background: var(--rating-color);
  }

  @media (hover: hover) {
    .rating > input[type="radio"]:not(:disabled):hover,
    .rating > input[type="radio"]:has(~ input[type="radio"]:not(:disabled):hover) {
      background: var(--rating-color);
    }

    /* A hovered value previews exactly that value: later stars are emptied,
       including a previously checked star beyond the pointer. */
    .rating > input[type="radio"]:not(:disabled):hover ~ input[type="radio"] {
      background: var(--rating-empty);
    }
  }
}

.rating.sm {
  --rating-size: 1rem;
}

.rating.lg {
  --rating-size: 1.75rem;
}

@media (forced-colors: active) {
  .rating > input[type="radio"] {
    appearance: auto;
    inline-size: auto;
    block-size: auto;
    background: Canvas;
    -webkit-mask: none;
    mask: none;
    opacity: 1;
  }
}

/*
 * Steps — semantic ordered-list progression for a multi-step process.
 *
 * One primitive, two explicit compositions. Both classes are required:
 *
 *   <ol class="steps steps-horizontal">  horizontal, 2 to 5 stages
 *   <ol class="steps steps-vertical">    vertical, any number of stages
 *
 * `.steps` is what a step *is* — the counter, the marker, `.step-label`,
 * `.step-complete`, `aria-current="step"`. It carries no orientation, so it is
 * never written alone. The two orientations are peers: no rule says
 * `:not(...)` and no rule undoes another's layout.
 *
 * Orientation and labels are independent. `.step-label` is optional in either;
 * when a sequence carries names, every step carries one:
 *
 *                 empty steps           .step-label on every step
 *   horizontal    compact progression   named sequence, stacked or inline
 *   vertical      compact rail          named sequence, labels beside markers
 *
 * A label may be an `a[href]`, which is the whole interactive surface: the
 * marker is never navigable, so navigating a flow means naming its
 * destinations. Anything else focusable in a step is out of contract.
 *
 * The framework never hides an authored label to solve a layout problem. A
 * labelled row that outgrows its space scrolls. An `actual-container` size
 * context buys it one thing: the wide representation, marker beside label.
 *
 * See docs/design-notes/steps.md for the threshold, the selector shapes, the
 * rejected alternatives, and why there is no forced-colors branch.
 *
 * Each public hook is followed by the class that owns it.
 *
 * Public hooks:
 * --step-size              .steps
 * --step-inline-gap        .steps
 * --step-line-size         .steps
 * --step-marker-radius     .steps
 * --step-complete-mark     .steps
 * --step-min               .steps-horizontal
 * --step-gap               .steps-horizontal
 * --step-connector         .steps-horizontal
 * --step-inline-connector  .steps-horizontal
 * --step-vertical-gap      .steps-vertical
 *
 * Internal:
 * --step-marker-bg
 * --step-marker-border
 * --step-marker-fg
 * --step-line
 */

/* Step primitive --------------------------------------------------------- */

.steps {
  --step-size: 1.75rem;
  --step-inline-gap: var(--space-20);
  --step-line-size: var(--border-width);
  --step-marker-bg: var(--surface);
  --step-marker-border: var(--border);
  --step-marker-fg: var(--text-muted);
  --step-marker-radius: var(--radius-full);
  --step-line: var(--border);

  counter-reset: actual-step;
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
}

.steps > li {
  position: relative;
  color: var(--text-muted);
  counter-increment: actual-step;
}

.steps > li::before {
  content: counter(actual-step);
  position: relative;
  z-index: 1;
  display: inline-grid;
  place-items: center;
  inline-size: var(--step-size);
  block-size: var(--step-size);
  border: var(--border-width) solid var(--step-marker-border);
  border-radius: var(--step-marker-radius);
  background: var(--step-marker-bg);
  color: var(--step-marker-fg);
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-semibold);
  line-height: 1;
}

/* The marker carries the state, so the label reads a notch below it — set on
   the component, never per representation: a size that changed with the layout
   would resize the text under a reader dragging a window. `color: inherit` is
   here rather than in a rule of its own for `a.step-label`, because a span
   inherits its ink anyway; only a link has anything to take back, and taking it
   back for every label costs nothing and one rule less. A navigable label keeps
   its underline, hover and focus ring — just not a link color, which would read
   ahead of the marker states.

   Scoped to `.steps`, unlike `.menu-item-text` and the framework's other
   sub-part classes: a bare `.step-label` weighs (0,1,0), exactly tying
   `.prose :where(a)`, so a stepper in prose would be decided by bundle order. */
.steps .step-label {
  font-size: var(--font-size-sm);
  line-height: var(--line-height-tight);
  color: inherit;
}

/* Each composition gives the connector its own geometry and background, so the
   only shared fact is which steps get one — every step but the last. Stated as
   the condition rather than as a `content: none` rule undoing a `content: ""`
   rule: a pseudo-element with no content generates no box, so a composition can
   address `> li::after` freely and still not trail a line past the last step. */
.steps > li:not(:last-child)::after {
  content: "";
}

/* Prefixed like `.step-label`, unlike the framework's bare `.primary` /
   `.outline` modifiers: those qualify a component on its own element, this
   marks one item inside one. A bare `.complete` would also claim a very
   ordinary English word globally, for a single component's single state. */
.steps > .step-complete {
  --step-marker-bg: var(--state-selected);
  --step-marker-border: var(--state-selected);
  --step-marker-fg: var(--state-selected-fg);
  --step-line: var(--state-selected);

  color: var(--text);
}

/* Complete owns the accented connector that reaches the current step, so the
   connector leaving current goes back to neutral. Declared after
   .step-complete at equal specificity: current wins on every channel. */
.steps > [aria-current="step"] {
  --step-marker-bg: var(--surface);
  --step-marker-border: var(--state-selected);
  --step-marker-fg: var(--state-selected);
  --step-line: var(--border);

  color: var(--text);
}

.steps > [aria-current="step"]::before {
  border-width: calc(var(--border-width) * 2);
}

/* No label weight change for current: the accented marker with its doubled
   border already carries the state, and selection never changes text metrics.
   Complete needs none either: its filled disc already reads as strongly. */

.steps > .step-complete:not([aria-current="step"])::before {
  content: var(--step-complete-mark, counter(actual-step));
}

/* Horizontal ------------------------------------------------------------- */

.steps-horizontal {
  --step-min: 7rem;
  --step-gap: var(--space-20);

  inline-size: 100%;
  overflow-x: auto;
  /* Stated, never left to `overflow-x` alone: one scrolling axis computes the
     other to `auto`, and a row hugs fractional content closely enough that a
     rounding difference then raises a phantom vertical scrollbar. */
  overflow-y: hidden;
  overscroll-behavior-inline: contain;
}

/* A scroll container clips at the padding edge, so a navigable row reserves the
   focus ring's own width and offset. Only this composition scrolls, and only a
   link has a ring to protect. */
.steps-horizontal:has(a[href]) {
  padding-block: calc(var(--focus-outline-offset) + var(--border-width) * 2);
}

/* One explicit row, the marker's: a `.step-label` is auto-placed into an
   implicit second row, and two tracks are also the only thing `gap` has to sit
   between. So an empty step measures exactly `--step-size`, and the rule below
   takes back nothing. `var(--step-size) auto` would build that second row
   whether or not a label ever arrives, and the compact form would then have to
   remove both the track and the gap. */
.steps-horizontal > li {
  display: grid;
  flex: 1 0 var(--step-min);
  grid-template-rows: var(--step-size);
  gap: var(--step-gap);
  justify-items: center;
  min-inline-size: max(var(--step-size), var(--step-min));
  text-align: center;
}

/* An empty step is a marker and nothing else, so it needs a marker's width
   rather than a reading width. That is what lets five of them fit a phone with
   no size context and no variant class.

   `justify-items` is not an override of the row's centring: the marker is
   centred because a label sits under it and the two share a centre, and with no
   label there is nothing to centre against. The marker takes the start of its
   own share instead, the connector below fills the rest of it, and the last
   step has no share to fill — so the track runs edge to edge and lines up with
   the text around it, the way the inline representation does.

   `:empty` says exactly what the markup says, the way `.badge:empty` does, and
   it reads the item's own content: bare text in an `<li>` keeps a reading width
   like its wrapped peers, where `> li:has(.step-label)` would give it a marker
   floor and skew the row. `:empty` also keeps the compact form reachable on the
   Degraded tier, where only the inline bonus needs `:has()`. */
.steps-horizontal > li:empty {
  flex: 1 1 0;
  justify-items: start;
  min-inline-size: var(--step-size);
}

.steps-horizontal > li:empty::after {
  inset-inline-start: var(--step-size);
}

.steps-horizontal > li:empty:last-child {
  flex: 0 0 auto;
}

/* The pseudo-element is marker-height so --step-connector may be any complete
   background value. */
.steps-horizontal > li::after {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: calc(50% + var(--step-size) / 2);
  inline-size: calc(100% - var(--step-size));
  block-size: var(--step-size);
  background: var(
    --step-connector,
    linear-gradient(var(--step-line), var(--step-line)) center /
    100% var(--step-line-size) no-repeat
  );
}

/*
 * The one horizontal container query: past 60rem a labelled row can put marker
 * and label inline and still leave the connector a real region between groups.
 * It counts nothing, so it serves every count; below it, stacked. A marker row
 * is already the shape this query produces and stays out of it.
 *
 * Horizontal is a 2-5 step component. Outside that domain the rule applies as
 * written — a row out of contract is not owed a curated fallback.
 */
@container actual-container (inline-size >= 60rem) {
  .steps-horizontal:has(.step-label) > li {
    display: flex;
    flex: 1 1 auto;
    align-items: center;
    gap: var(--step-inline-gap);
    min-inline-size: 0;
    text-align: start;
    white-space: nowrap;
  }

  .steps-horizontal:has(.step-label) > li::after {
    position: static;
    flex: 1 1 2rem;
    inline-size: auto;
    block-size: var(--step-size);
    margin-inline-end: var(--step-inline-gap);
    background: var(
      --step-inline-connector,
      var(
        --step-connector,
        linear-gradient(var(--step-line), var(--step-line)) center /
        100% var(--step-line-size) no-repeat
      )
    );
  }

  .steps-horizontal:has(.step-label) > li:last-child {
    flex: 0 0 auto;
  }
}

/* Vertical --------------------------------------------------------------- */

.steps-vertical {
  --step-vertical-gap: var(--space-40);

  flex-direction: column;
  gap: var(--step-vertical-gap);
}

/* `justify-items` looks like a reset and is not one — nothing here resets
   anything, since the horizontal rules never applied. Grid items blockify, so
   without it a label stretches the whole 1fr track: 264px against 75px for an
   `a.step-label`, a full-width focus ring the row does not give the same link. */
.steps-vertical > li {
  display: grid;
  grid-template-columns: var(--step-size) minmax(0, 1fr);
  align-items: start;
  justify-items: start;
  gap: var(--step-inline-gap);
  min-block-size: var(--step-size);
}

/* An empty step is a marker rail. The column cannot use the row's implicit
   second track, because `grid-auto-flow` is `row`: an auto-placed label would
   land *under* its marker. Its second track is therefore explicit, and this
   takes it back.

   Silent in a stretched column, where the item fills its line whatever its
   tracks say. It bites in a shrink-to-fit box — which is what a sticky
   progress rail usually is — where the label track and the gap to it make the
   rail `--step-inline-gap` wider than the markers it draws: measured 36px
   against 28px. No `gap: 0` needed; one track has nothing to sit beside. */
.steps-vertical > li:empty {
  grid-template-columns: var(--step-size);
}

/* The row starts at the top so a wrapped label begins level with its marker.
   align-self only bites on a single line, which would otherwise float above
   the marker's centre.

   The one `.step-label` rule that keeps a child combinator: `align-self` is a
   grid-item property, so this declaration is meaningless on anything but a
   direct child of the row. Elsewhere the label is addressed as a descendant —
   the framework owns the structure, and repeating it buys nothing. */
.steps-vertical > li > .step-label {
  align-self: center;
}

/* The vertical track publishes no hook of its own: a horizontal connector value
   cannot transpose onto this geometry, and this selector is already a reachable
   override for the design that finally asks. */
.steps-vertical > li::after {
  position: absolute;
  inset-block-start: var(--step-size);
  inset-inline-start: calc((var(--step-size) - var(--step-line-size)) / 2);
  inline-size: var(--step-line-size);
  block-size: calc(100% - var(--step-size) + var(--step-vertical-gap));
  background:
    linear-gradient(var(--step-line), var(--step-line)) center / var(--step-line-size) 100%
    no-repeat;
}


/* Navigation */
/*
 * Primary application navigation. The component owns the navigation surface
 * and item states; app-layout owns whether that surface is a bottom bar or
 * labelled side navigation.
 *
 * Public hooks:
 * --app-nav-size
 * --app-nav-gap
 * --app-nav-icon-size
 */
:where(.app-nav) {
  --intent: initial;
  --intent-fg: initial;
  --intent-soft-fg: initial;
  --ui-bg: initial;
  --ui-fg: initial;
  --ui-border: initial;
}

.app-nav {
  --app-nav-size: 4rem;
  --app-nav-gap: var(--space-10);
  --app-nav-icon-size: 1.5rem;

  position: sticky;
  inset-block-end: 0;
  z-index: var(--z-sticky);
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: var(--app-nav-gap);
  min-block-size: calc(var(--app-nav-size) + env(safe-area-inset-bottom));
  padding-block: var(--space-10) max(var(--space-10), env(safe-area-inset-bottom));
  padding-inline: var(--space-10);
  border-block-start: var(--border-width) solid var(--ui-border, var(--border));
  background: var(--ui-bg, var(--surface));
  color: var(--ui-fg, var(--text-muted));
}

.app-nav > a {
  display: grid;
  grid-template-rows: auto auto;
  place-content: center;
  justify-items: center;
  gap: var(--space-10);
  min-inline-size: 0;
  min-block-size: var(--control-size-lg);
  padding-inline: var(--space-10);
  border-radius: var(--radius);
  color: inherit;
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-medium);
  line-height: var(--line-height-tight);
  text-align: center;
  text-decoration: none;
  overflow-wrap: anywhere;
}

.app-nav > a > :where(svg, img, [aria-hidden="true"]) {
  inline-size: var(--app-nav-icon-size);
  block-size: var(--app-nav-icon-size);
  font-size: var(--app-nav-icon-size);
  line-height: 1;
}

.app-nav > a:hover {
  background: var(--surface-subtle);
  color: var(--text);
}

/* Current tile is already a full selected surface. Selection never changes
   text metrics: the shared medium weight is kept, no permanent indicator is
   added here. */
.app-nav > a[aria-current="page"] {
  background: var(--state-selected);
  color: var(--state-selected-fg);
}

@media (forced-colors: active) {
  /* --state-selected flattens to a UA color that no longer distinguishes the
     current tile from the rest of the bar, so paint the system Highlight pair. */
  .app-nav > a[aria-current="page"] {
    background: Highlight;
    color: HighlightText;
  }
}

/*
 * Navbar — top-level navigation bar.
 *
 * .navbar is a horizontal bar; .navbar-brand is the brand link; .navbar-nav is
 * the inline link list using .nav-link items. .nav-list is the vertical nav
 * list shared with .drawer, fed by the shared --gap token.
 *
 * Internal:
 * --gap (local value for the shared gap token)
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity.
   The intent tokens are reset alongside --ui-*: the shared variants
   (.soft/.solid/.outline/.surface) read --intent / --intent-fg / --intent-soft-fg
   when they paint this surface, so an ancestor intent would otherwise leak
   through a variant that has no local intent class. */
:where(.navbar) {
  --intent: initial;
  --intent-fg: initial;
  --intent-soft-fg: initial;
  --ui-bg: initial;
  --ui-fg: initial;
  --ui-border: initial;
}

.navbar {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap);
  padding-block: var(--space-30);
  padding-inline: var(--space-40);
  border: var(--border-width) solid var(--ui-border, var(--border));
  border-radius: var(--radius-lg);
  background: var(--ui-bg, var(--surface-raised));
  color: var(--ui-fg, var(--text));
}

.navbar-brand {
  display: inline-flex;
  align-items: center;
  gap: var(--space-20);
  color: var(--ui-fg, var(--text));
  font-weight: var(--font-weight-bold);
  text-decoration: none;
}

.navbar-brand:focus-visible {
  border-radius: var(--radius-sm);
}

.navbar-nav {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-10);
  margin: 0;
  padding: 0;
  list-style: none;
}

.nav-link {
  display: inline-flex;
  align-items: center;
  min-block-size: var(--control-size-sm);
  padding-inline: var(--space-20);
  border-radius: var(--radius);
  color: var(--ui-fg, var(--text-muted));
  font-weight: var(--font-weight-medium);
  text-decoration: none;
}

/* nav-link serves two contracts: plain page navigation (aria-current="page")
   and scrollspy, which toggles aria-current="location" on the active link.
   Presence is the contract: the attribute is set on the current link and
   removed everywhere else, never serialized as aria-current="false".
   Selection never changes text metrics: the shared medium weight is kept and
   the current link is carried by the selected accent. */
.nav-link[aria-current] {
  background: var(--surface-subtle);
  color: var(--state-selected);
}

.nav-link:hover {
  background: var(--surface-subtle);
  color: var(--text);
}

/* A current link keeps its accent on hover: without this, the hover rule above
   would drop it back to --text. The surface hover still applies. */
.nav-link[aria-current]:hover {
  color: var(--state-selected);
}

/* Nav list — a vertical navigation list. Used by .drawer for stacked links.
   Links fill each row so the whole line is an interactive target; the
   horizontal .navbar-nav links keep their intrinsic size. */
.nav-list {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  --gap: var(--space-20);
  gap: var(--gap);
  margin: 0;
  padding: 0;
  list-style: none;
}

.nav-list > li {
  margin: 0;
}

.nav-list .nav-link {
  position: relative;
  inline-size: 100%;
}

/* Vertical current indicator: inset inline-start trait in currentColor, so it
   follows the forced palette without a forced-colors block and never touches
   the row separators. Navbar horizontal links keep color + surface only. The
   positioning context lives on every row link, so becoming current never moves
   an app-positioned decoration to a new containing block. */
.nav-list .nav-link[aria-current]::before {
  content: "";
  position: absolute;
  inset-block: var(--space-20);
  inset-inline-start: 0;
  border-inline-start: 2px solid currentColor;
}

/*
 * Breadcrumb — current-location trail from the site root.
 *
 * Inline list separated by "/". Mark the current page with `aria-current="page"`
 * either on the item or on the current link.
 */
.breadcrumb {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-10);
  margin: 0;
  padding: 0;
  color: var(--text-subtle);
  font-size: var(--font-size-sm);
  list-style: none;
}

.breadcrumb li {
  display: inline-flex;
  align-items: center;
  gap: var(--space-10);
}

.breadcrumb li + li::before {
  content: "/";
  color: var(--text-subtle);
}

.breadcrumb a {
  color: var(--text-muted);
  text-decoration: none;
}

.breadcrumb a:not([aria-current="page"]):hover {
  color: var(--text);
  text-decoration: underline;
}

.breadcrumb a:focus-visible {
  border-radius: var(--radius-sm);
}

.breadcrumb :where(li, a, span)[aria-current="page"] {
  color: var(--text);
  font-weight: var(--font-weight-medium);
}

/*
 * Pagination — page navigation for paginated lists.
 *
 * Render .btn elements inside a .pagination list. The active page is marked
 * with aria-current="page"; .btn owns the active state styling.
 *
 * Truncated ranges are plain text (`.muted`, `aria-hidden="true"`), not .btn
 * — they are decorative, not actionable. They still get the same box sizing
 * as .btn below so the row stays visually aligned.
 */
.pagination {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-10);
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--control-font-size);

  /* Arrows, page numbers, and truncation ellipses share one geometric
     system: equal digit widths keep the row from re-flowing as numbers grow
     (98 → 99 → 100). */
  font-variant-numeric: tabular-nums;
}

.pagination .btn {
  /* Every item gets the same min footprint; wider content (100, 1000,
     text labels) grows past it naturally. The default 1em control padding
     would let an icon-only arrow exceed the numbered items' footprint, so
     the horizontal padding tightens here and the min size does the work. */
  --btn-pad-x: var(--space-20);

  min-inline-size: var(--control-size);
}

.pagination > li > [aria-hidden="true"] {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-inline-size: var(--control-size);
}

/*
 * Tabs — in-place panel switcher with real tab semantics.
 *
 * JavaScript owns roving tabindex, aria-selected, panel visibility,
 * and arrow-key navigation. CSS owns the tab strip appearance and
 * active indicator.
 *
 * aria-orientation is matched inside :where() so it keeps describing which
 * arrow keys move between tabs without also carrying cascade weight: an
 * author class can then adjust a vertical strip without restating the
 * attribute to reach its specificity.
 *
 * Public hooks:
 * --tab-gap
 */
/* Intent boundary — inherited intents do not tint this component.
   Local intent classes still win because :where() has zero specificity. */
:where(.tab) {
  --intent: initial;
}

.tabs {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--space-10);
  margin: 0;
  padding: 0;
  border-block-end: var(--border-width) solid var(--border);
  list-style: none;
}

.tabs:where([aria-orientation="vertical"]) {
  display: grid;
  align-content: start;
  justify-items: stretch;
  border-block-end: 0;
  border-inline-end: var(--border-width) solid var(--border);
}

.tab {
  /* Positioning context for the selected indicator pseudo. */
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--tab-gap, 0.375em);
  min-block-size: var(--control-size);
  padding-inline: var(--space-30);
  border: 0;
  border-radius: 0;
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--control-font-size);
  /* Selection must not change text metrics: a heavier active label would shift
     the strip. The active state is carried by color and the indicator line. */
  font-weight: var(--font-weight-semibold);
  text-decoration: none;
  cursor: pointer;
}

.tabs:where([aria-orientation="vertical"]) .tab {
  justify-content: flex-start;
  padding-inline-end: var(--space-40);
}

.tab:is(:disabled, [aria-disabled="true"]) {
  cursor: not-allowed;
  opacity: var(--disabled-opacity);
}

.tab:hover:not(:where(:disabled, [aria-disabled="true"])) {
  color: var(--text);
}

/* The ring is square on the edge the tab is attached to, so it reads as part
   of the strip rather than a floating chip. */
.tab:focus-visible {
  outline: var(--focus-outline);
  outline-color: var(--focus);
  outline-offset: -2px;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}

/* A vertical tab is attached along its inline end, not its block end, so the
   horizontal geometry above would round the wrong pair of corners. Logical
   corners keep it correct in RTL. */
.tabs:where([aria-orientation="vertical"]) .tab:focus-visible {
  border-start-start-radius: var(--radius-sm);
  border-start-end-radius: 0;
  border-end-start-radius: var(--radius-sm);
  border-end-end-radius: 0;
}

.tab[aria-selected="true"],
.tab[aria-current="page"] {
  color: var(--intent, var(--state-selected));
}

/* The selected indicator lives on a positioned pseudo so that only the active
   tab owns an underline: a transparent border on every tab would surface in
   forced colors and erase the distinction. currentColor follows the palette the
   UA forces, so no forced-colors block is needed. */
.tab[aria-selected="true"]::after,
.tab[aria-current="page"]::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block-end: 0;
  border-block-end: 2px solid currentColor;
}

.tabs:where([aria-orientation="vertical"]) .tab[aria-selected="true"]::after,
.tabs:where([aria-orientation="vertical"]) .tab[aria-current="page"]::after {
  inset-block: 0;
  inset-inline: auto 0;
  border-block-end: 0;
  border-inline-end: 2px solid currentColor;
}

/*
 * Scrollspy — behavior hook for navigation that marks the active section
 * while the page scrolls.
 *
 * Does not add visual styles by itself. scrollspy.js watches
 * <nav class="scrollspy">
 * containing <a href="#section"> links and toggles aria-current="location" on
 * the link whose target is in view. Pair with .nav-list and .nav-link, or style
 * [aria-current] yourself.
 *
 * Progressive enhancement: on browsers that support :target-current,
 * this file enables the native target group and marks the active link.
 *
 * Falls back gracefully: links still work when :target-current is unavailable
 * and when the JavaScript enhancer is not loaded at all.
 */

@supports selector(.scrollspy a:target-current) {
  .scrollspy {
    scroll-target-group: auto;
  }

  .scrollspy a:target-current {
    color: var(--text);
    font-weight: var(--font-weight-medium);
  }
}


/* Data */
/*
 * Data list — term/value pairs for the properties of a single object.
 *
 * Expects alternating <dt>/<dd> children: one term, one value per property.
 * A plain <dl> handles richer relationships (several terms or descriptions)
 * and stays untouched by this component.
 *
 * Why margin resets: neutralizes .prose list margins if .data-list appears
 * inside an authored rich-text scope; the grid owns row/column rhythm.
 */
.data-list {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  align-items: baseline;
  gap: var(--space-20) var(--space-40);
}

.data-list > dt {
  min-inline-size: 0;
  margin-block-start: 0;
  font-weight: var(--font-weight-semibold);
}

.data-list > dd {
  min-inline-size: 0;
  margin: 0;
}

/*
 * Application list — repeated rows with optional leading and trailing regions.
 * Content determines the number of lines; the component only owns the row
 * geometry, dividers, and supporting-text treatment.
 *
 * Public hooks:
 * --list-item-min-size
 * --list-item-pad-block
 * --list-item-pad-inline
 * --list-item-gap
 * --list-divider
 */
.list {
  --list-item-min-size: var(--control-size-lg);
  --list-item-pad-block: var(--space-30);
  --list-item-pad-inline: 0;
  --list-item-gap: var(--gap);
  --list-divider: var(--border-width) solid var(--border);

  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0;
  margin: 0;
  padding: 0;
  border-block-start: var(--list-divider);
  list-style: none;
}

.list > li {
  margin: 0;
}

/*
 * Flex, not a three-track grid: the leading and trailing regions are optional,
 * and a fixed `auto minmax(0, 1fr) auto` template gives an absent region a
 * track anyway — the content of a row with no leading region lands in the
 * leading track and the trailing control inherits the flexible one, which is
 * the opposite of the intent. Flex drops both the track and its gap when a
 * region is not there, so all four shapes work off one rule.
 */
.list > :where(.list-item),
.list > li > .list-item {
  display: flex;
  /* Regions align to the first line, so leading, title, and trailing controls
     share one horizontal line while supporting text wraps beneath. */
  align-items: start;
  gap: var(--list-item-gap);
  min-block-size: var(--list-item-min-size);
  padding-block: var(--list-item-pad-block);
  padding-inline: var(--list-item-pad-inline);
}

.list > .list-item + .list-item,
.list > li + li {
  border-block-start: var(--list-divider);
}

a.list-item {
  position: relative;
  color: inherit;
  text-decoration: none;
}

a.list-item:hover {
  background: var(--surface-subtle);
}

/* Current row in a navigational list (master/detail). Generic by design: the
   visual treatment only cares that the row is current, so presence is the
   contract — the attribute is set on the current row and removed everywhere
   else, never serialized as aria-current="false". The value (page, location,
   …) stays free for assistive technology.
   Calm by design: the row keeps its normal text metrics and color on a subtle
   surface, and the selected accent lives on an inset inline-start trait only.
   The trait is a real border, so it survives forced colors without a repaint
   block. The positioning context lives on every row link, so becoming current
   never moves an app-positioned decoration to a new containing block. */
a.list-item[aria-current] {
  background: var(--surface-subtle);
}

a.list-item[aria-current]::before {
  content: "";
  position: absolute;
  inset-block: var(--list-item-pad-block);
  inset-inline-start: 0;
  border-inline-start: 2px solid var(--state-selected);
}

/* The content region is the one that flexes, whichever position it holds. */
.list-item-content {
  flex: 1 1 0;
  min-inline-size: 0;
}

.list-item-title,
.list-item-text {
  display: block;
  margin: 0;
  overflow-wrap: anywhere;
}

.list-item-text {
  color: var(--text-muted);
  font-size: var(--font-size-sm);
}

/* The auto margin keeps the trailing region at the row's end even in a row
   with no content region to absorb the free space. */
.list-item-trailing {
  flex: none;
  margin-inline-start: auto;
  min-inline-size: 0;
}

@media print {
  .list > :where(.list-item),
  .list > li > .list-item {
    break-inside: avoid;
  }
}

/*
 * Table — structured tabular data.
 *
 * .table-wrap provides the scroll container and outer chrome; .table owns row
 * and cell rules. Tokens are sourced from the shared scale.
 *
 * Public hooks:
 * --table-min
 * --table-cell-pad
 */
.table-wrap {
  inline-size: 100%;
  max-inline-size: 100%;
  min-inline-size: 0;
  overflow-x: auto;
  border: var(--border-width) solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--surface-raised);
}

.table {
  --table-min: 100%;
  --table-cell-pad: var(--space-30);

  inline-size: max(var(--table-min), 100%);
  border-collapse: collapse;
  background: transparent;
  color: var(--text);
  font-size: var(--font-size-md);
}

.table caption {
  padding: var(--table-cell-pad);
  color: var(--text-muted);
  text-align: var(--text-align, start);
}

.table th,
.table td {
  padding: var(--table-cell-pad);
  border-block-end: var(--border-width) solid var(--border);
  text-align: var(--text-align, start);
  vertical-align: baseline;
}

.table th {
  color: var(--text-muted);
  font-weight: var(--font-weight-semibold);
}

.table thead th {
  vertical-align: bottom;
}

.table thead {
  background: var(--surface-subtle);
}

.table tbody tr:hover {
  background-color: var(--hover-overlay);
}

.table > tr:last-child > *,
.table > :last-child > tr:last-child > * {
  border-block-end: 0;
}

/* Same compact vocabulary as .card.compact: one hook change, no selector
   duplication. Density is padding only — typography never changes. */
.table.compact {
  --table-cell-pad: var(--space-20);
}

/*
 * Meter — native and segmented gauges for known-range values.
 *
 * Maps the three standard meter zones (optimum / suboptimum / even-less-good)
 * to the shared success / warning / danger intent colors.
 *
 * Thickness is a dimension, not a density step: the bar always spans its
 * container and only gets thinner or thicker, so --bar-height is the API and
 * .sm/.lg are deliberately not supported.
 *
 * Public hooks:
 * --meter-value
 * --bar-height
 */

.meter {
  appearance: none;
  -webkit-appearance: none;
  display: block;
  inline-size: 100%;
  block-size: var(--bar-height);
  overflow: hidden;
  border: 0;
  border-radius: var(--radius-full);
  background: var(--surface-subtle);
}

/* A non-native meter is a weighted flex container. The element type keeps the
   native <meter> API and the composed segmented API distinct without another
   public variant class. */
.meter:not(meter) {
  --intent: initial;

  display: flex;
}

.meter:not(meter) > * {
  flex: var(--meter-value, 1) 1 0;
  min-inline-size: 0;
  background: var(--intent);
}

.meter:not(meter) > * + * {
  border-inline-start: var(--border-width) solid var(--surface);
}

.meter::-webkit-meter-bar {
  -webkit-appearance: none;
  block-size: var(--bar-height);
  border: 0;
  border-radius: var(--radius-full);
  background: var(--surface-subtle);
}

.meter::-webkit-meter-optimum-value {
  block-size: var(--bar-height);
  border-radius: var(--radius-full);
  background: var(--success);
}

.meter::-webkit-meter-suboptimum-value {
  block-size: var(--bar-height);
  border-radius: var(--radius-full);
  background: var(--warning);
}

.meter::-webkit-meter-even-less-good-value {
  block-size: var(--bar-height);
  border-radius: var(--radius-full);
  background: var(--danger);
}

.meter::-moz-meter-bar {
  block-size: var(--bar-height);
  border-radius: var(--radius-full);
}

@media (forced-colors: active) {
  .meter::-webkit-meter-optimum-value,
  .meter::-webkit-meter-suboptimum-value,
  .meter::-webkit-meter-even-less-good-value {
    background: Highlight;
  }

  .meter::-moz-meter-bar {
    background: Highlight;
  }
}


/* Composed controls — imported last so equal-specificity corner rules remain
   later than the controls they group. */
/*
 * Join — visually groups adjacent controls into a single unit.
 *
 * This is a cross-cutting control primitive, not a forms-only component:
 * it applies equally to inputs, selects, buttons, and button-like links.
 *
 * Cascade note: .join writes border-radius longhands on its direct children.
 * Keep this file loaded after control/component styles so those corners win at
 * equal specificity.
 *
 * Public hooks:
 * --join-radius
 *
 * Internal:
 * --join-ss
 * --join-se
 * --join-es
 * --join-ee
 * --control-border
 * --control-bg
 * --control-fg
 */
.join {
  --join-radius: var(--radius);
  --join-ss: 0;
  --join-se: 0;
  --join-es: 0;
  --join-ee: 0;

  align-items: stretch;
  display: inline-flex;
}

/* The .circle utility cannot round a join: corners are written as longhands
   on the children below, so the group radius must flow through the token. */
.join.circle {
  --join-radius: var(--radius-full);
}

.join-addon {
  --control-border: var(--border);
  --control-bg: var(--surface-subtle);
  --control-fg: var(--text-muted);

  align-items: center;
  display: inline-flex;
  justify-content: center;
  min-block-size: var(--control-size);
  padding-inline: var(--space-30);
  border: var(--border-width) solid var(--control-border);
  border-radius: var(--radius);
  background: var(--control-bg);
  color: var(--control-fg);
  font-size: var(--control-font-size);
  line-height: 1;
  white-space: nowrap;
}

.join > * {
  border-start-start-radius: var(--join-ss);
  border-start-end-radius: var(--join-se);
  border-end-end-radius: var(--join-ee);
  border-end-start-radius: var(--join-es);
}

/* A square icon button has an explicit block size, which otherwise prevents
   align-items: stretch from matching a taller labelled sibling. The button's
   existing min-block-size keeps the standalone square-size invariant intact. */
.join > .btn.icon-only {
  block-size: auto;
}

.join > :first-child {
  --join-ss: var(--join-radius);
  --join-es: var(--join-radius);
}

.join > :last-child {
  --join-se: var(--join-radius);
  --join-ee: var(--join-radius);
}

.join > :only-child {
  --join-ss: var(--join-radius);
  --join-se: var(--join-radius);
  --join-es: var(--join-radius);
  --join-ee: var(--join-radius);
}

.join > * + * {
  margin-inline-start: calc(var(--border-width) * -1);
}

.join > :is(:hover, :focus, :focus-within) {
  z-index: 1;
}

/* Keep an active focus ring above an adjacent hovered control. This matters
   on platforms where clicking a button does not move focus from the input. */
.join > :is(:focus, :focus-within) {
  z-index: 4;
}

/* An input's border must read as the "primary" edge against an attached
   action (e.g. a password-reveal button) at all times, not only while
   hovered or focused — otherwise the shared edge flips ownership as the
   user's pointer moves, which reads as a rendering glitch. */
.join > :is(.input, .textarea, .select) {
  z-index: 3;
}

/* An input/select with an attached action reads as one composite field. Move
   its outer focus treatment to the join; button-only groups keep per-button
   focus, and unsupported :has() browsers retain the control's normal ring. */
.join:has(> :is(.input, .textarea, .select):focus-visible) {
  border-radius: var(--join-radius);
  outline: 2px solid transparent;
  box-shadow: var(--focus-ring-shadow);
}

.join:has(> :is(.input, .textarea, .select)) > :is(.input, .textarea, .select):focus-visible {
  outline: none;
  box-shadow: none;
}

/* A default outline action belongs to the composite field border. An explicit
   intent still provides its own outline color — --intent is guaranteed-invalid
   for a neutral button (the :where(.btn) boundary), so the fallback applies. */
.join:has(> :is(.input, .textarea, .select)) > .btn.outline {
  --btn-border-color: var(--intent, var(--border));
}

/* Effects — purely visual enrichments. */
/*
 * Decorative intent-colored frame for one direct child.
 *
 * The effect is built from three layers with distinct roles: a frame carrying
 * a short accent of light, a small halo that belongs to the frame and keeps
 * that light attached to the edge, and a wide static halo that belongs to the
 * object and gives it a permanent, diffuse presence. Only the small halo
 * follows the animated gradient, so a rotating aura repaints one blurred layer
 * per frame, not two.
 *
 * Public hooks:
 * --aura-color
 * --aura-width
 * --aura-blur
 * --aura-intensity
 * --aura-radius
 * --aura-duration
 *
 * Internal:
 * --aura-angle
 */
@property --aura-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

/* Intent boundary — an outer intent does not tint nested aura frames. */
:where(.aura) {
  --intent: initial;
  --intent-fg: initial;
}

.aura {
  --aura-color: var(--intent, var(--primary));
  --aura-width: 2px;
  --aura-blur: 0.25rem;
  --aura-intensity: 1;
  --aura-radius: var(--radius-lg);
  --aura-duration: 6s;

  position: relative;
  isolation: isolate;
  display: inline-grid;
  padding: var(--aura-width);
  border-radius: calc(var(--aura-radius) + var(--aura-width));
  border: var(--border-width) solid transparent;
  background: var(--aura-color);

  /* A short feathered accent rather than a long wedge with a hard edge, which
     reads as one lit side. Nothing tints the frame outside it: a constant tint
     across 2px draws an outline, the opposite of the effect. */
  background: conic-gradient(
    from var(--aura-angle),
    transparent 0 78%,
    var(--aura-color) 93%,
    transparent
  );
}

/* Near halo — inherits the frame gradient, so it tracks the lit accent. */
.aura::before {
  position: absolute;
  z-index: -1;
  inset: 0;
  border-radius: inherit;
  background: inherit;
  filter: blur(var(--aura-blur));
  opacity: calc(0.55 * var(--aura-intensity));
  pointer-events: none;
  content: "";
}

/* Far halo — flat and static. At this blur the rotating detail is no longer
   legible, so there is nothing to gain from animating a second wide blurred
   layer. It outgrows the frame, otherwise the child covers it and the whole
   effect reads as a drop shadow. This is the layer that stays lit at all
   times, so it — not the frame — is what keeps the object present between two
   passes of the accent. */
.aura::after {
  position: absolute;
  z-index: -1;
  inset: calc(-1 * var(--aura-blur));
  border-radius: calc(var(--aura-radius) + var(--aura-width) + var(--aura-blur));
  background: var(--aura-color);
  filter: blur(calc(var(--aura-blur) * 4));
  opacity: calc(0.15 * var(--aura-intensity));
  pointer-events: none;
  content: "";
}

/* Lifts the child above both halos. It also has to be opaque: the frame paints
   across the whole box and only its edge is meant to show, so a transparent
   child (an .outline alert, a .ghost button) is filled with the frame color
   rather than framed by it. Documented as a constraint, not worked around —
   masking a ring would cost more than the case is worth. */
.aura > * {
  position: relative;
}

/* Glow lights the whole perimeter instead of a short accent, so both halos
   become a single soft field around the child. */
.aura-glow {
  background: var(--aura-color);
}

@media (prefers-reduced-motion: no-preference) {
  .aura:not(.aura-glow) {
    animation: aura-rotate var(--aura-duration) linear infinite;
  }

  /* Both halos breathe: moving the near one alone changes a line's brightness,
     which barely registers. Opening the wide field with it is what reads as a
     breath. */
  .aura-glow::before {
    animation: aura-breathe-near var(--aura-duration) ease-in-out infinite;
  }

  .aura-glow::after {
    animation: aura-breathe-far var(--aura-duration) ease-in-out infinite;
  }
}

/* Without motion the accent would freeze at an arbitrary angle and read as a
   broken animation, so it becomes a deliberate composition: two opposite
   corners. Fixed radii keep those corners stable across aspect ratios;
   proportional radii turn wide or tall elements into lit edges instead. */
@media (prefers-reduced-motion: reduce) {
  .aura:not(.aura-glow) {
    background:
      radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%),
      radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%);
  }
}

@keyframes aura-rotate {
  to {
    --aura-angle: 360deg;
  }
}

/* Only the peak is declared, so each halo breathes up from its own resting
   opacity and never dips below the static glow. The peaks scale with the same
   intensity as the resting values, which keeps the breath proportional at
   every setting. */
@keyframes aura-breathe-near {
  50% {
    opacity: calc(0.95 * var(--aura-intensity));
    transform: scale(1.03);
  }
}

@keyframes aura-breathe-far {
  50% {
    opacity: calc(0.28 * var(--aura-intensity));
    transform: scale(1.07);
  }
}

/* Each rule is a lone :has() setting a decorative radius, so a browser without
   :has() simply drops it and keeps the default. No @supports needed — that
   guard only earns its place around a selector list that mixes :has() with
   selectors worth keeping. */
.aura:has(> .btn) {
  --aura-radius: var(--radius);
}

.aura:has(> :is(.card, .alert)) {
  --aura-radius: var(--radius-lg);
}

.aura:has(> .badge) {
  --aura-radius: var(--radius-full);
}

/* Utilities — small, single-purpose escape hatches. */
/*
 * Small, single-purpose utilities for common escape hatches.
 *
 * These support components and layout primitives without becoming a
 * utility-first API.
 */
.sr-only {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  /* clip alone is legacy-only; clip-path is the modern hiding mechanism. */
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Enlarge a small control's touch target on touch-primary devices without
   growing the visual. Opt-in — apply to genuinely small targets only. The
   ::after exists only under (pointer: coarse), so on fine pointers the class
   is fully neutral. */
@media (pointer: coarse) {
  .touch-target {
    position: relative;
  }

  .touch-target::after {
    content: "";
    position: absolute;
    inset: calc(var(--space-20) * -1);
  }
}

.overflow-auto {
  overflow: auto;
}

.fit {
  inline-size: fit-content;
  max-inline-size: 100%;
}

.grow {
  flex: 1 1 auto;
  min-inline-size: 0;
}

.text-nowrap {
  white-space: nowrap;
}

.truncate {
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.items-start {
  --items-align: start;
  align-items: start;
}

.items-center {
  --items-align: center;
  align-items: center;
}

.items-end {
  --items-align: end;
  align-items: end;
}

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

/* Circle: forces a fully round shape without mutating global radius tokens.
   Works on any component because utilities load after components. .join is
   the one exception: it writes corner longhands on its children, so
   .join.circle keeps its own --join-radius hook (join.css). */
.circle {
  border-radius: var(--radius-full);
}

/* Text alignment — uses logical properties so it follows writing direction. */
.text-start {
  --text-align: start;
  text-align: start;
}

.text-center {
  --text-align: center;
  text-align: center;
}

.text-end {
  --text-align: end;
  text-align: end;
}

/* Text wrapping utilities. Unsupported values are ignored by the browser. */
.text-balance {
  text-wrap: balance;
}

.text-pretty {
  text-wrap: pretty;
}

/* Generic list chrome reset. Use for any list (tags, footer columns, related
   items). For navigation lists, prefer .nav-list for semantic intent.
   components/{actions,navbar}.css apply their own resets where needed. */
.list-reset {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Separator dot for inline metadata rows (byline, timestamp, read time,
   breadcrumb-like facts). Pair with a flex/cluster container; flex: none
   keeps a flex parent from shrinking it to nothing. */
.dot {
  flex: none;
  inline-size: 0.25rem;
  block-size: 0.25rem;
  border-radius: var(--radius-full);
  background: var(--text-subtle);
}

/* Link variants for chrome that should not look like prose links. Use in
   navigation, tags, footer, tables of contents, and similar surfaces. */
.link-plain {
  color: inherit;
  text-decoration: none;
}

.link-muted {
  color: var(--text-muted);
  text-decoration: none;
}

.link-muted:hover,
.link-muted:focus-visible {
  color: var(--text);
}

/* Spacing step helpers — semantic scale (sm / default / lg).
   Names map to logical properties for writing-direction safety. The default
   step uses --space-40, which matches the framework's built-in padding on
   cards, dialogs, and form-actions. */

/* Gap */
.gap-none {
  gap: 0;
}

/* Padding — block (vertical) */
.py {
  padding-block: var(--density-space);
}

/* Padding — inline (horizontal) */
.px {
  padding-inline: var(--density-space);
}

/* Margin — block-start */
.mbs {
  margin-block-start: var(--density-space);
}

/* Margin — block-end */
.mbe {
  margin-block-end: var(--density-space);
}

/*
 * Optional utility helpers.
 *
 * These classes are intentionally not part of the core API.
 * They are escape hatches for layout nudges, not a full atomic scale.
 *
 * Core principle:
 * - .compact / .spacious provide inherited rhythm through --gap and
 *   --density-space.
 * - utilities consume the current context instead of defining their own -sm/-lg scale.
 * - optional utilities extend core utilities, they do not re-express them.
 * - follow verbose .{property}-{value} naming.
 *
 * Internal:
 * --cluster-justify (local relay consumed by .cluster)
 */

/* Display */
.display-block {
  display: block;
}

.display-inline {
  display: inline;
}

.display-inline-block {
  display: inline-block;
}

.display-flex {
  display: flex;
}

.display-inline-flex {
  display: inline-flex;
}

.display-grid {
  display: grid;
}

/* Position */
.position-relative {
  position: relative;
}

.position-absolute {
  position: absolute;
}

.position-fixed {
  position: fixed;
}

.position-sticky {
  position: sticky;
}

/* Sizing */
.inline-size-full {
  inline-size: 100%;
}

.block-size-full {
  block-size: 100%;
}

.min-inline-size-0 {
  min-inline-size: 0;
}

.min-block-size-0 {
  min-block-size: 0;
}

.max-inline-size-none {
  max-inline-size: none;
}

/* Flex / alignment */
.flex-wrap {
  flex-wrap: wrap;
}

.flex-nowrap {
  flex-wrap: nowrap;
}

.justify-content-start {
  --cluster-justify: flex-start;
  justify-content: flex-start;
}

.justify-content-center {
  --cluster-justify: center;
  justify-content: center;
}

.justify-content-end {
  --cluster-justify: flex-end;
  justify-content: flex-end;
}

.justify-content-space-between {
  --cluster-justify: space-between;
  justify-content: space-between;
}

.align-self-start {
  align-self: start;
}

.align-self-center {
  align-self: center;
}

.align-self-end {
  align-self: end;
}

/* Spacing */
.padding-context {
  padding: var(--density-space);
}

.margin-inline-auto {
  margin-inline: auto;
}

.margin-inline-start-auto {
  margin-inline-start: auto;
}

.margin-inline-end-auto {
  margin-inline-end: auto;
}

.margin-block-start-auto {
  margin-block-start: auto;
}

.margin-block-end-auto {
  margin-block-end: auto;
}

/* Consume the inherited --gap rhythm on an arbitrary layout container.
   Setting --gap alone only defines the custom property; it has no visual
   effect unless the element already uses gap: var(--gap) (as .stack,
   .cluster, .grid, etc. do). */
.gap-context {
  gap: var(--gap);
}

.row-gap-context {
  row-gap: var(--gap);
}

.column-gap-context {
  column-gap: var(--gap);
}

/* Overflow */
.overflow-hidden {
  overflow: hidden;
}

.overflow-clip {
  overflow: clip;
}

.overflow-x-auto {
  overflow-x: auto;
}

.overflow-y-auto {
  overflow-y: auto;
}

/* Text */
.white-space-normal {
  white-space: normal;
}

.text-break {
  overflow-wrap: break-word;
}

.text-uppercase {
  text-transform: uppercase;
}

.text-lowercase {
  text-transform: lowercase;
}

.text-capitalize {
  text-transform: capitalize;
}

.font-weight-normal {
  font-weight: var(--font-weight-normal);
}

.font-weight-medium {
  font-weight: var(--font-weight-medium);
}

.font-weight-semibold {
  font-weight: var(--font-weight-semibold);
}

.font-weight-bold {
  font-weight: var(--font-weight-bold);
}

/* Radius */
.radius-sm-context {
  border-radius: var(--radius-sm);
}

.radius-context {
  border-radius: var(--radius);
}

.radius-lg-context {
  border-radius: var(--radius-lg);
}

/* Border */
.border-context {
  border: var(--border-width) solid var(--border);
}

.border-block-start {
  border-block-start: var(--border-width) solid var(--border);
}

.border-block-end {
  border-block-end: var(--border-width) solid var(--border);
}

.border-inline-start {
  border-inline-start: var(--border-width) solid var(--border);
}

.border-inline-end {
  border-inline-end: var(--border-width) solid var(--border);
}

/* Surface. These are semantic surfaces, not arbitrary paint: each owns its
   matching ink and rebinds --heading to that ink, so it stays readable when it
   recreates a light surface inside a context that installed a dark one
   (.inverted). Intent ink is not inherited through a surface utility; use
   .intent-color for that. */
.background-surface {
  --heading: currentColor;

  background: var(--surface);
  color: var(--text);
}

.background-raised {
  --heading: currentColor;

  background: var(--surface-raised);
  color: var(--text);
}

.background-subtle {
  --heading: currentColor;

  background: var(--surface-subtle);
  color: var(--text);
}

/* Shadow */
.shadow-context {
  box-shadow: var(--shadow);
}

.shadow-popout-context {
  box-shadow: var(--shadow-popout);
}

/* Interaction */
.cursor-pointer {
  cursor: pointer;
}

.pointer-events-none {
  pointer-events: none;
}

.user-select-none {
  -webkit-user-select: none;
  user-select: none;
}

/* Visibility */
.display-none {
  display: none;
}

.visibility-hidden {
  visibility: hidden;
}
