/* Hand-maintained import manifest for CONSUMERS: base tokens plus one
   file per component. Adding a component = create its CSS file + add a
   line here.

   Storybook-only chrome (shell, demo scaffolding, playground) lives in
   storybook.css, which imports this file — so nothing here is anything a
   consumer app cannot use. */

/* css/base.css */
/* ------------------------------------------------------------------ */
/* Reset                                                               */
/* ------------------------------------------------------------------ */

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

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

body {
  min-height: 100dvh;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

img,
picture,
video,
canvas,
svg {
  display: block;
  max-width: 100%;
}

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

button {
  background: none;
  border: none;
  cursor: pointer;
}

a {
  color: inherit;
}

ul[role="list"],
ol[role="list"] {
  list-style: none;
}

/* ------------------------------------------------------------------ */
/* Design tokens (shadcn-inspired, zinc)                               */
/* ------------------------------------------------------------------ */

/* The default family, "zinc". Its two variants are the document default
   and the .dark class, and ALSO answer to data-theme="light"/"dark" —
   the same attribute every other family binds to — so a variant can be
   scoped to any subtree (a theme picker's swatches, a docs preview)
   regardless of what the document itself is rendered in. Other
   families live in themes/<family>.css and use only the attribute. */
:root,
[data-theme="light"] {
  color-scheme: light;

  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --destructive-foreground: oklch(0.985 0 0);
  --success: oklch(0.627 0.17 149.2);
  --success-foreground: oklch(0.985 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --overlay: oklch(0 0 0 / 0.5);

  /* Charts carry no palette of their own: series are drawn in the
     foreground and muted-foreground inks, colour only on strokes and
     only where it means something (a dataset's tone). A consumer that
     wants coloured strokes may define --chart-1 … --chart-5; the chart
     glue reads them per slot and falls back to the neutral ramp. */

  --radius: 0.625rem;
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);

  --shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.05);
  --shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.1), 0 2px 4px -2px oklch(0 0 0 / 0.1);
  --shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.1), 0 4px 6px -4px oklch(0 0 0 / 0.1);

  --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  --text-xs: 0.75rem;
  --text-sm: 0.875rem;
  --text-base: 1rem;
  --text-lg: 1.125rem;
  --text-xl: 1.25rem;
  --text-2xl: 1.5rem;
  --text-3xl: 1.875rem;
}

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

  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.205 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --destructive-foreground: oklch(0.985 0 0);
  --success: oklch(0.72 0.17 149.2);
  --success-foreground: oklch(0.205 0 0);
  --border: oklch(1 0 0 / 0.1);
  --input: oklch(1 0 0 / 0.15);
  --ring: oklch(0.556 0 0);
  --overlay: oklch(0 0 0 / 0.7);
}

/* ------------------------------------------------------------------ */
/* Base                                                                */
/* ------------------------------------------------------------------ */

body {
  font-family: var(--font-sans);
  font-size: var(--text-sm);
  background: var(--background);
  color: var(--foreground);
}

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

::selection {
  background: var(--primary);
  color: var(--primary-foreground);
}

h1 { font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.025em; }
h2 { font-size: var(--text-2xl); font-weight: 600; letter-spacing: -0.025em; }
h3 { font-size: var(--text-xl); font-weight: 600; }
h4 { font-size: var(--text-lg); font-weight: 600; }

code {
  font-family: var(--font-mono);
  font-size: 0.85em;
  background: var(--muted);
  padding: 0.15em 0.35em;
  border-radius: var(--radius-sm);
}

/* ------------------------------------------------------------------ */
/* Shared layout utilities                                             */
/* ------------------------------------------------------------------ */

.container {
  width: 100%;
  max-width: 72rem;
  margin-inline: auto;
  padding-inline: 1rem;
}

.stack {
  display: flex;
  flex-direction: column;
}

.stack--row {
  flex-direction: row;
}

.stack--wrap { flex-wrap: wrap; }

.stack--align-start { align-items: flex-start; }
.stack--align-center { align-items: center; }
.stack--align-end { align-items: flex-end; }
.stack--align-stretch { align-items: stretch; }

.stack--justify-start { justify-content: flex-start; }
.stack--justify-center { justify-content: center; }
.stack--justify-end { justify-content: flex-end; }
.stack--justify-between { justify-content: space-between; }

.grid {
  display: grid;
}

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

/* Auto-fit variant: as many columns as --grid-fit-min allows, then reflow.
   Fixed-count grids force content into whatever sliver is left, which is
   how a KPI figure ends up wrapped mid-number; this one trades columns for
   whole rows instead. The inner min() keeps a min wider than the container
   from overflowing it. */
.grid--fit {
  grid-template-columns: repeat(auto-fit, minmax(min(var(--grid-fit-min, 9rem), 100%), 1fr));
}
.grid--cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid--cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid--cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid--cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.grid--cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.grid--cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); }

@media (min-width: 768px) {
  .grid--md-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
  .grid--md-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .grid--md-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .grid--md-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .grid--md-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}

@media (min-width: 1024px) {
  .grid--lg-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
  .grid--lg-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .grid--lg-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .grid--lg-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .grid--lg-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}

.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-8 { gap: 2rem; }

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

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

/* htmx request-in-flight affordance */
.htmx-request {
  opacity: 0.7;
  pointer-events: none;
  transition: opacity 0.15s ease;
}

/* ------------------------------------------------------------------ */
/* Icons                                                               */
/* ------------------------------------------------------------------ */

.icon {
  width: 1em;
  height: 1em;
  flex-shrink: 0;
  display: inline-block;
  vertical-align: -0.125em;
}

/* ------------------------------------------------------------------ */
/* Scrollbars (WebKit + Firefox)                                       */
/* ------------------------------------------------------------------ */

* {
  scrollbar-width: thin;
  scrollbar-color: color-mix(in oklab, var(--muted-foreground), transparent 60%) transparent;
}

*::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

*::-webkit-scrollbar-track {
  background: transparent;
}

*::-webkit-scrollbar-thumb {
  background: color-mix(in oklab, var(--muted-foreground), transparent 60%);
  border-radius: 999px;
}

*::-webkit-scrollbar-thumb:hover {
  background: color-mix(in oklab, var(--muted-foreground), transparent 35%);
}

/* ------------------------------------------------------------------ */
/* Motion                                                              */
/* ------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}


/* css/components/appshell.css */
/* AppShell: application chrome — sticky header, sidebar nav, main pane.
   The nav contents (links, groups, filters) are whatever the consumer
   puts inside AppShellNav; Sidebar's own styles live in sidebar.css. */

.appshell {
  --appshell-header-h: 3.3rem;
  --appshell-nav-w: 260px;

  display: grid;
  grid-template-areas:
    "header header"
    "sidebar main";
  grid-template-columns: var(--appshell-nav-w) 1fr;
  grid-template-rows: auto 1fr;
  min-height: 100dvh;
}

.appshell__header {
  grid-area: header;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem 1.25rem;
  border-bottom: 1px solid var(--border);
  position: sticky;
  top: 0;
  background: color-mix(in oklab, var(--background), transparent 12%);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  z-index: 40;
  height: var(--appshell-header-h);
}

.appshell__header-left {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

/* The center slot takes the slack; margin-auto on the actions keeps them
   pinned right whether or not a center slot is present. */
.appshell__center {
  flex: 1;
  min-width: 0;
}

.appshell__actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-left: auto;
}

.appshell__brand {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-weight: 700;
  font-size: var(--text-lg);
  letter-spacing: -0.025em;
  min-width: 0;
}

.appshell__brand a {
  text-decoration: none;
  color: inherit;
}

/* Mobile nav toggle (hamburger <-> X). Hidden on desktop. The checkbox it
   drives lives inside the AppShellNav swap unit, so selecting an item
   re-renders it unchecked and the menu closes. */

/* Every state selector below uses the CHILD combinator on both sides:
   :has(> .appshell__nav-state:checked) > .appshell__sidebar, never the
   descendant form. A shell can contain another shell — the docs page
   embeds one inside the gallery's own — and with descendant selectors
   the outer shell matches on the INNER shell's checkbox, so opening the
   inner sheet silently drove the outer one. Each shell must answer only
   to the state that is its own direct child.

   The checkboxes ARE the toggles. Visually hidden but still focusable
   and still in the tab order, so Space works and screen readers announce
   them; the labels are their visible skin. display:none would drop them
   out of the tab order entirely, which is why it is not used here — and
   why they live outside the sidebar, which IS display:none on mobile. */

.appshell__nav-state,
.appshell__collapse-state {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Focus lands on the hidden checkbox; the ring has to show on the
   control the user can actually see. */
.appshell:has(> .appshell__nav-state:focus-visible) > .appshell__header .appshell__nav-toggle,
.appshell:has(> .appshell__collapse-state:focus-visible) > .appshell__header .appshell__collapse-toggle {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

/* Each checkbox is reachable exactly where its visible toggle exists.
   Below the breakpoint the collapse control is gone; above it the
   hamburger is. Tabbing to an invisible no-op control is worse than not
   reaching it at all. */

@media (min-width: 768px) {
  .appshell__nav-state {
    display: none;
  }
}

@media (max-width: 767px) {
  .appshell__collapse-state {
    display: none;
  }
}

.appshell__nav-toggle {
  display: none;
  position: relative;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: var(--radius-md);
  cursor: pointer;
  font-size: 1.1rem;
  color: var(--foreground);
  align-items: center;
  justify-content: center;
  transition: background-color 0.15s ease;
}

.appshell__nav-toggle:active {
  background: var(--accent);
}

.appshell__nav-toggle-icon {
  position: absolute;
  display: inline-flex;
  transition: opacity 0.2s ease, rotate 0.25s ease, scale 0.2s ease;
}

.appshell__nav-toggle-icon--close {
  opacity: 0;
  rotate: -90deg;
  scale: 0.5;
}

.appshell:has(> .appshell__nav-state:checked) > .appshell__header .appshell__nav-toggle-icon--menu {
  opacity: 0;
  rotate: 90deg;
  scale: 0.5;
}

.appshell:has(> .appshell__nav-state:checked) > .appshell__header .appshell__nav-toggle-icon--close {
  opacity: 1;
  rotate: 0deg;
  scale: 1;
}

/* Sidebar container: THE scroll container. It is never swapped by htmx
   (only the AppShellNav unit inside it is), so scroll position survives
   navigation. */

.appshell__sidebar {
  grid-area: sidebar;
  border-right: 1px solid var(--border);
  padding: 0 0.75rem 2rem;
  overflow-y: auto;
  position: sticky;
  top: var(--appshell-header-h);
  height: calc(100dvh - var(--appshell-header-h));
}

/* A sticky filter inside the nav owns the top spacing with an OPAQUE
   background, so scrolled items can never peek out around it. */
.appshell__sidebar .sidebar__filter {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--background);
  padding: 1rem 0 0.6rem;
}

.appshell__main {
  grid-area: main;
  padding: 2rem 2.5rem 4rem;
  min-width: 0;
  /* Hook for htmx's viewTransition swap modifier; inert until used. */
  view-transition-name: appshell-main;
}

/* Desktop-only sidebar collapse. Purely CSS: the hidden checkbox holds
   the state, the label flips it, and the consumer may persist it with a
   fire-and-forget POST. */

.appshell__collapse-toggle {
  display: none;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: var(--radius-md);
  cursor: pointer;
  color: var(--muted-foreground);
  transition: background-color 0.15s ease, color 0.15s ease, rotate 0.2s ease;
}

.appshell__collapse-toggle:hover {
  background: var(--accent);
  color: var(--foreground);
}

@media (min-width: 768px) {
  .appshell__collapse-toggle {
    display: inline-flex;
  }

  .appshell:has(> .appshell__collapse-state:checked) {
    grid-template-columns: 0 1fr;
  }

  .appshell:has(> .appshell__collapse-state:checked) > .appshell__sidebar {
    display: none;
  }

  .appshell:has(> .appshell__collapse-state:checked) > .appshell__header .appshell__collapse-toggle {
    rotate: 180deg;
  }
}

@media (max-width: 767px) {
  .appshell {
    grid-template-areas:
      "header"
      "main";
    grid-template-columns: 1fr;
    grid-template-rows: auto 1fr;
  }

  .appshell__nav-toggle {
    display: inline-flex;
  }

  /* Hidden by default; the checked nav-state turns it into a fullscreen
     sheet below the header. */
  .appshell__sidebar {
    display: none;
  }

  .appshell:has(> .appshell__nav-state:checked) > .appshell__sidebar {
    display: block;
    position: fixed;
    top: var(--appshell-header-h);
    left: 0;
    right: 0;
    bottom: 0;
    height: auto;
    z-index: 30;
    background: var(--background);
    border-right: none;
    padding: 0 1rem calc(1.5rem + env(safe-area-inset-bottom));
    overflow-y: auto;
    animation: appshell-nav-in 0.2s ease;
  }

  @keyframes appshell-nav-in {
    from { opacity: 0; translate: 0 -0.5rem; }
    to { opacity: 1; translate: 0 0; }
  }

  /* Roomier touch targets in the fullscreen menu. Consumers opt a
     hand-rolled nav row into this by giving it appshell__nav-item — the
     shell owns sheet sizing, so nobody has to know how the open state is
     represented. ds's own Sidebar links carry the class already. */
  .appshell:has(> .appshell__nav-state:checked) > .appshell__sidebar .appshell__nav-item {
    padding: 0.55rem 0.6rem;
    font-size: var(--text-base);
  }

  .appshell__main {
    padding: 1.25rem 1rem 6rem;
  }
}

/* Embedded variant: an AppShell inside a page (docs previews). It stops
   claiming the viewport — no sticky positioning, bounded height. */

.appshell--embedded {
  min-height: 0;
  height: 24rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  /* Containing block for the mobile sheet, which must stay inside the
     embedded shell instead of covering the page. */
  position: relative;
  --appshell-nav-w: 180px;
}

@media (max-width: 767px) {
  .appshell--embedded:has(> .appshell__nav-state:checked) > .appshell__sidebar {
    position: absolute;
  }
}

.appshell--embedded .appshell__header {
  position: static;
  padding: 0.5rem 0.75rem;
}

.appshell--embedded .appshell__sidebar {
  position: static;
  height: auto;
  overflow-y: auto;
  padding: 0.5rem;
}

.appshell--embedded .appshell__main {
  padding: 1rem 1.25rem;
  overflow-y: auto;
  /* One view-transition-name may only be used once per document. */
  view-transition-name: none;
}

/* Theme toggle: sun and moon cross-fade + rotate on theme change. Also
   used by the storybook chrome. */

.theme-toggle {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  font-size: 1.05rem;
  color: var(--foreground);
  transition: background-color 0.15s ease;
}

.theme-toggle:hover {
  background: var(--accent);
}

.theme-toggle:active {
  scale: 0.95;
}

.theme-toggle__icon {
  position: absolute;
  display: inline-flex;
  transition: opacity 0.25s ease, rotate 0.35s ease, scale 0.25s ease;
}

.theme-toggle__icon--dark {
  opacity: 0;
  rotate: 90deg;
  scale: 0.5;
}

.dark .theme-toggle__icon--light {
  opacity: 0;
  rotate: -90deg;
  scale: 0.5;
}

.dark .theme-toggle__icon--dark {
  opacity: 1;
  rotate: 0deg;
  scale: 1;
}

/* css/components/live.css */
/* Live region + connection status.

   The region itself is a connector, not a layout element: in the common
   (named-event) shape it holds nothing at all, so it must not occupy space
   or a stray gap appears wherever a page opens its stream. In the broadcast
   shape it wraps real content, so display is restored the moment it has any. */
.live-region:empty {
  display: none;
}

.live-status {
  margin-bottom: 1rem;
}

/* [hidden] must win over the alert's display. */
.live-status[hidden] {
  display: none;
}

/* css/components/sidebar.css */
.sidebar {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  min-height: 0;
}

/* Filter */

.sidebar__filter {
  position: relative;
  display: flex;
  align-items: center;
  flex-shrink: 0;
}

.sidebar__filter > .icon {
  position: absolute;
  left: 0.6rem;
  font-size: 0.85rem;
  color: var(--muted-foreground);
  pointer-events: none;
}

.sidebar__filter-input {
  width: 100%;
  height: 2rem;
  padding: 0 0.6rem 0 2rem;
  border: 1px solid var(--input);
  border-radius: var(--radius-md);
  background: transparent;
  font-size: var(--text-sm);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.sidebar__filter-input::placeholder {
  color: var(--muted-foreground);
}

.sidebar__filter-input:focus-visible {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 70%);
}

.sidebar__filter-input::-webkit-search-cancel-button {
  -webkit-appearance: none;
}

/* Nav list */

.sidebar__nav {
  display: flex;
  flex-direction: column;
  gap: 1.1rem;
  min-height: 0;
}

.sidebar__group-label {
  font-size: var(--text-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted-foreground);
  padding: 0 0.6rem 0.35rem;
}

.sidebar__link {
  position: relative;
  /* Flex rather than block so an optional icon sits beside the label
     without the label wrapping under it. With no icon the result is
     identical to the block it replaced. */
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.32rem 0.6rem;
  border-radius: var(--radius-sm);
  text-decoration: none;
  font-size: var(--text-sm);
  color: var(--muted-foreground);
  transition: color 0.12s ease, background-color 0.12s ease;
}

.sidebar__link:hover {
  color: var(--foreground);
  background: color-mix(in oklab, var(--accent), transparent 40%);
}

.sidebar__link:active {
  background: var(--accent);
}

.sidebar__link--active {
  color: var(--foreground);
  background: var(--accent);
  font-weight: 500;
}

/* Active indicator bar */
.sidebar__link--active::before {
  content: "";
  position: absolute;
  left: -0.35rem;
  top: 0.45rem;
  bottom: 0.45rem;
  width: 3px;
  border-radius: 999px;
  background: var(--primary);
}

.sidebar__empty {
  padding: 0.5rem 0.6rem;
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

/* Optional leading icon. Sized in em so it tracks the label, and it
   never shrinks when the label is long enough to ellipsize. */
.sidebar__link-icon {
  display: inline-flex;
  flex: 0 0 auto;
  font-size: 1.05em;
  opacity: 0.75;
}

.sidebar__link--active .sidebar__link-icon,
.sidebar__link:hover .sidebar__link-icon {
  opacity: 1;
}

/* css/components/button.css */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  height: 2.25rem;
  padding: 0 1rem;
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
  font-weight: 500;
  white-space: nowrap;
  border: 1px solid transparent;
  background: var(--primary);
  color: var(--primary-foreground);
  box-shadow: var(--shadow-sm);
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease,
    opacity 0.15s ease, scale 0.1s ease, box-shadow 0.15s ease;
  text-decoration: none;
  user-select: none;
}

/* Hover shifts the fill toward its own text colour rather than fading
   the button: a translucent button shows whatever sits behind it, and
   on a themed surface that reads as dirt, not emphasis. */
.btn:hover {
  background: color-mix(in oklab, var(--primary), var(--primary-foreground) 10%);
}

.btn:active {
  scale: 0.97;
}

.btn:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 60%);
}

.btn:disabled {
  pointer-events: none;
  opacity: 0.5;
}

.btn > .icon {
  font-size: 1em;
}

.btn--secondary {
  background: var(--secondary);
  color: var(--secondary-foreground);
}

.btn--secondary:hover { background: color-mix(in oklab, var(--secondary), var(--foreground) 6%); }

.btn--destructive {
  background: var(--destructive);
  color: var(--destructive-foreground);
}

.btn--destructive:hover {
  background: color-mix(in oklab, var(--destructive), var(--destructive-foreground) 10%);
}

.btn--outline {
  background: var(--background);
  color: var(--foreground);
  border-color: var(--border);
}

.btn--outline:hover { background: var(--accent); }

.btn--ghost {
  background: transparent;
  color: var(--foreground);
  box-shadow: none;
}

.btn--ghost:hover { background: var(--accent); }

/* Quiet destructive: ghost weight, destructive only in the text. The
   hover tint is what confirms the intent at the moment of clicking —
   the colour alone is not the signal, since a red-blind reader would
   otherwise see a plain ghost button. */
.btn--destructive-ghost {
  background: transparent;
  color: var(--destructive);
  box-shadow: none;
}

.btn--destructive-ghost:hover {
  background: color-mix(in oklch, var(--destructive) 12%, transparent);
}

.btn--link,
.btn--link:hover {
  background: transparent;
  color: var(--foreground);
  box-shadow: none;
  text-decoration: underline;
  text-underline-offset: 4px;
  padding: 0;
  height: auto;
}

.btn--sm {
  height: 2rem;
  padding: 0 0.75rem;
  border-radius: var(--radius-sm);
}

.btn--lg {
  height: 2.5rem;
  padding: 0 1.5rem;
}

.btn--icon {
  width: 2.25rem;
  padding: 0;
}

/* css/components/input.css */
.input {
  display: block;
  width: 100%;
  height: 2.25rem;
  padding: 0 0.75rem;
  border: 1px solid var(--input);
  border-radius: var(--radius-md);
  background: transparent;
  font-size: var(--text-sm);
  box-shadow: var(--shadow-sm);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.input::placeholder {
  color: var(--muted-foreground);
}

.input:hover:not(:disabled):not(:focus-visible) {
  border-color: color-mix(in oklab, var(--input), var(--ring) 45%);
}

.input:focus-visible {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 70%);
}

.input:disabled {
  opacity: 0.5;
  pointer-events: none;
}

.input[aria-invalid="true"] {
  border-color: var(--destructive);
}

.input[aria-invalid="true"]:focus-visible {
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--destructive), transparent 70%);
}

.textarea {
  height: auto;
  min-height: 4rem;
  padding: 0.5rem 0.75rem;
  resize: vertical;
}

/* Last in the file on purpose: this matches `.input` exactly, so the cascade
   is decided by order, and anywhere above the base declaration it silently
   does nothing at all.

   Below 16px, iOS Safari zooms the page when a control takes focus — and
   does not zoom back out, so one tap on a search box leaves someone
   panning a page that no longer fits. The design system's control text is
   14px, which is the right size with a mouse and a trap under a finger.

   The condition is the touch device, not the narrow window: an iPad in
   portrait is 768px wide and zooms exactly the same way, so a width-only
   rule fixes phones and leaves tablets broken. The width clause stays for
   the narrow-window case a coarse-pointer query does not catch. */
@media (max-width: 767px), (hover: none) and (pointer: coarse) {
  .input,
  .textarea,
  .select__control,
  .multiselect__input {
    font-size: 1rem;
  }
}

/* css/components/label.css */
.label {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: 1;
  cursor: pointer;
  user-select: none;
}

/* A label whose control is disabled must read as disabled itself:
   dim the whole row and switch the cursor. Covers controls nested
   inside the label (:has) and adjacent-sibling layouts. */
.label:has(:disabled),
:disabled + .label {
  opacity: 0.55;
  cursor: not-allowed;
}

/* Plain field labels (no nested control) keep the default cursor. */
.label:not(:has(input, select, textarea, button)) {
  cursor: unset;
}

/* css/components/select.css */
.select {
  appearance: none;
  display: block;
  width: 100%;
  height: 2.25rem;
  padding: 0 2.25rem 0 0.75rem;
  border: 1px solid var(--input);
  border-radius: var(--radius-md);
  background-color: transparent;
  font-size: var(--text-sm);
  box-shadow: var(--shadow-sm);
  cursor: pointer;
  /* chevron */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.6rem center;
}

.select:hover:not(:disabled) {
  border-color: var(--ring);
}

.select:focus-visible {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 70%);
}

.select:disabled {
  opacity: 0.5;
  pointer-events: none;
}

.select[aria-invalid="true"] {
  border-color: var(--destructive);
}

.select option {
  background: var(--popover);
  color: var(--popover-foreground);
}

/* css/components/checkbox.css */
.checkbox {
  appearance: none;
  width: 1.125rem;
  height: 1.125rem;
  flex-shrink: 0;
  border: 1px solid var(--input);
  border-radius: var(--radius-sm);
  background: var(--background);
  box-shadow: var(--shadow-sm);
  cursor: pointer;
  display: inline-grid;
  place-content: center;
  transition: background-color 0.15s ease, border-color 0.15s ease,
    box-shadow 0.15s ease, scale 0.1s ease;
}

.checkbox:hover:not(:disabled):not(:checked) {
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 85%);
}

.checkbox:focus-visible {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 60%);
}

.checkbox:active:not(:disabled) {
  scale: 0.9;
}

.checkbox:checked {
  background: var(--primary);
  border-color: var(--primary);
}

.checkbox:checked:hover:not(:disabled) {
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary), transparent 85%);
}

.checkbox:checked::before {
  content: "";
  width: 0.7rem;
  height: 0.7rem;
  background: var(--primary-foreground);
  /* check mark */
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center / contain no-repeat;
  animation: check-pop 0.25s cubic-bezier(0.3, 1.4, 0.6, 1);
}

@keyframes check-pop {
  from { scale: 0; rotate: -12deg; }
  to { scale: 1; rotate: 0deg; }
}

/* Disabled: dimmed, and the cursor says so. Events still land (no
   pointer-events: none) so the not-allowed cursor actually shows;
   a disabled input ignores them anyway. */
.checkbox:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  box-shadow: none;
}

/* Radio */

.radio-group {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.radio-group__item {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
  cursor: pointer;
}

.radio-group__item--disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.radio {
  appearance: none;
  width: 1.125rem;
  height: 1.125rem;
  flex-shrink: 0;
  margin-top: 0.05rem;
  border: 1px solid var(--input);
  border-radius: 50%;
  background: var(--background);
  box-shadow: var(--shadow-sm);
  cursor: pointer;
  display: inline-grid;
  place-content: center;
  transition: border-color 0.15s ease, box-shadow 0.15s ease, scale 0.1s ease;
}

.radio:hover:not(:disabled):not(:checked) {
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 85%);
}

.radio:focus-visible {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 60%);
}

.radio:active:not(:disabled) {
  scale: 0.9;
}

.radio:checked {
  border-color: var(--primary);
}

.radio:checked::before {
  content: "";
  width: 0.6rem;
  height: 0.6rem;
  border-radius: 50%;
  background: var(--primary);
  animation: check-pop 0.25s cubic-bezier(0.3, 1.4, 0.6, 1);
}

.radio:disabled {
  cursor: not-allowed;
  box-shadow: none;
}

.radio-group__text {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.radio-group__label {
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: 1.3;
}

.radio-group__description {
  font-size: var(--text-xs);
  color: var(--muted-foreground);
}

/* css/components/switch.css */
.switch {
  appearance: none;
  width: 2rem;
  height: 1.15rem;
  flex-shrink: 0;
  border-radius: 999px;
  background: var(--input);
  cursor: pointer;
  position: relative;
  transition: background-color 0.15s ease, box-shadow 0.15s ease;
}

/* The thumb is the one place the scheme matters beyond the tokens: on a
   light track it is the page white, on a dark track it has to be the
   light foreground or it vanishes. light-dark() reads the element's
   color-scheme, which every theme sets, so a dark theme scoped to a
   subtree (a theme picker's preview) renders correctly too — a .dark
   selector would only ever see the document root. */
.switch::before {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: calc(1.15rem - 4px);
  height: calc(1.15rem - 4px);
  border-radius: 999px;
  background: light-dark(var(--background), var(--foreground));
  box-shadow: var(--shadow-sm);
  transition: translate 0.18s cubic-bezier(0.3, 1.3, 0.6, 1), width 0.15s ease;
}

@media (hover: hover) {
  .switch:hover:not(:disabled):not(:checked) {
    background: color-mix(in oklab, var(--input), var(--muted-foreground) 15%);
  }
}

.switch:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 60%);
}

/* Thumb stretches slightly while pressed, like a real switch. */
.switch:active:not(:disabled)::before {
  width: calc(1.15rem - 1px);
}

.switch:checked {
  background: var(--primary);
}

.switch:checked::before {
  translate: 0.85rem 0;
  background: light-dark(var(--background), var(--primary-foreground));
}

/* Disabled: structurally distinct, not just dimmed — a hollow outlined
   track with a flat muted thumb can't be mistaken for an enabled-off
   switch (which has a filled track and an elevated thumb). */
.switch:disabled {
  cursor: not-allowed;
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--input);
}

.switch:disabled::before {
  background: var(--input);
  box-shadow: none;
}

.switch:disabled:checked {
  background: color-mix(in oklab, var(--primary), var(--background) 70%);
  box-shadow: none;
}

.switch:disabled:checked::before {
  background: var(--background);
}

/* css/components/field.css */
.field {
  display: grid;
  gap: 0.5rem;
  width: 100%;
}

.field__error {
  font-size: var(--text-xs);
  font-weight: 500;
  color: var(--destructive);
}

.field__help {
  font-size: var(--text-xs);
  color: var(--muted-foreground);
}

/* css/components/fileupload.css */
/* FileUpload: a styled native file input. The button half is the
   ::file-selector-button pseudo-element, so the control stays a real
   <input type="file"> — keyboard, screen readers and multipart all keep
   working without a wrapper faking any of it. */

.file-upload {
  display: flex;
  min-width: 0;
}

.file-upload__input {
  width: 100%;
  min-width: 0;
  padding: 0.35rem;
  border: 1px dashed var(--input);
  border-radius: var(--radius-md);
  background: var(--background);
  font-size: var(--text-sm);
  color: var(--muted-foreground);
  cursor: pointer;
  transition: border-color 0.12s ease, background-color 0.12s ease;
}

.file-upload__input::file-selector-button {
  margin-right: 0.75rem;
  padding: 0.3rem 0.7rem;
  border: 0;
  border-radius: var(--radius-sm);
  background: var(--secondary);
  color: var(--secondary-foreground);
  font: inherit;
  font-weight: 500;
  cursor: pointer;
}

@media (hover: hover) {
  .file-upload__input:hover {
    border-color: var(--ring);
  }

  .file-upload__input:hover::file-selector-button {
    background: var(--accent);
    color: var(--accent-foreground);
  }
}

.file-upload__input:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

.file-upload__input[aria-invalid="true"] {
  border-color: var(--destructive);
  border-style: solid;
}

.file-upload__input:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* css/components/card.css */
.card {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  background: var(--card);
  color: var(--card-foreground);
  padding: 1.5rem 0;
  box-shadow: var(--shadow-sm);
}

.card__header {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  padding: 0 1.5rem;
}

.card__title {
  font-size: var(--text-base);
  font-weight: 600;
  line-height: 1.2;
}

.card__description {
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

.card__content {
  padding: 0 1.5rem;
}

.card__footer {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0 1.5rem;
}

/* css/components/board.css */
/* Board: kanban-style lanes on desktop, collapsible card groups on
   mobile. One markup, media queries decide — see tabpanel.css and
   dropdown.css for the same philosophy. */

.board {
  display: flex;
  gap: 1rem;
  width: 100%;
}

.board__column {
  display: flex;
  flex-direction: column;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: color-mix(in oklab, var(--muted), transparent 45%);
}

.board__toggle {
  display: none;
}

.board__column-header,
.board__column-summary {
  flex-shrink: 0;
  padding: 0.6rem 0.85rem;
}

/* The two header shapes are styled together throughout: a <summary> and
   a <label> lay out identically, and letting them drift is how the
   collapsible column ends up looking like a different component. */
.board__column-label,
.board__column-summary {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: var(--text-sm);
  font-weight: 600;
  user-select: none;
}

.board__column-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.35rem;
  padding: 0 0.4rem;
  border-radius: 999px;
  background: color-mix(in oklab, var(--foreground), transparent 88%);
  color: var(--muted-foreground);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  line-height: 1.5;
}

/* The indicator belongs to the left group, with the name and the count:
   it is part of what the column IS, and it should be the thing you read
   immediately after the name rather than something parked in a corner. */
.board__column-indicator {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  min-width: 0;
}

/* Optional status badge sits at the end of the header, before the
   mobile chevron. */
.board__column-label > .badge,
.board__column-summary > .badge {
  margin-left: auto;
}

.board__column-chevron {
  display: inline-flex;
  margin-left: auto;
  color: var(--muted-foreground);
  font-size: 0.9em;
  transition: rotate 0.2s ease;
}

.board__column-label > .badge ~ .board__column-chevron,
.board__column-summary > .badge ~ .board__column-chevron {
  margin-left: 0;
}

.board__column-body {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  padding: 0 0.6rem 0.6rem;
  min-height: 0;
}

/* ---------------------------------------------------------------- */
/* Collapsible column: a real <details> disclosure at every width      */
/* ---------------------------------------------------------------- */

/* Nothing here hides or shows the body — <details> already does, and
   doing it again in CSS is how you end up with a column that is open to
   the browser and closed to the eye. All this styles is the summary. */

.board__column--collapsible > .board__column-summary {
  cursor: pointer;
  /* We draw the chevron ourselves; the UA triangle would be a second,
     differently-aligned disclosure arrow in the same row. */
  list-style: none;
}

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

/* Unlike the label column, this one is in the tab order, so it needs to
   show where the focus is. Inset, because the summary's own corners are
   the column's. */
.board__column--collapsible > .board__column-summary:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: -2px;
  border-radius: var(--radius-lg);
}

@media (hover: hover) {
  .board__column--collapsible > .board__column-summary:hover {
    background: color-mix(in oklab, var(--accent), transparent 55%);
    border-radius: var(--radius-lg);
  }
}

.board__column--collapsible[open] > .board__column-summary .board__column-chevron {
  rotate: 180deg;
}

/* Ticket */

.board__ticket {
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--card);
  color: var(--card-foreground);
  padding: 0.7rem 0.8rem;
  font-size: var(--text-sm);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.45rem;
  box-shadow: var(--shadow-sm);
  transition: border-color 0.15s ease, box-shadow 0.15s ease, translate 0.15s ease;
}

@media (hover: hover) {
  .board__ticket:hover {
    border-color: color-mix(in oklab, var(--border), var(--ring) 50%);
    box-shadow: var(--shadow-md);
    translate: 0 -1px;
  }
}

/* ---------------------------------------------------------------- */
/* Desktop: horizontally scrolling fixed-width lanes                  */
/* ---------------------------------------------------------------- */

@media (min-width: 768px) {
  .board {
    overflow-x: auto;
    scroll-snap-type: x proximity;
    padding-bottom: 0.5rem; /* room for the scrollbar */
    align-items: flex-start;
  }

  .board__column {
    flex: 0 0 18rem;
    max-height: min(70dvh, 34rem);
    scroll-snap-align: start;
  }

  /* For a default column, collapsing is a mobile affordance: neutralize
     the label and keep the body visible whatever the checkbox says (a
     column collapsed on mobile must not vanish after rotating to
     landscape). A collapsible column is opted into the other deal —
     it folds here too — so every rule below spares it. */
  .board__column:not(.board__column--collapsible) .board__column-label {
    pointer-events: none;
  }

  .board__column:not(.board__column--collapsible) .board__column-chevron {
    display: none;
  }

  .board__column:not(.board__column--collapsible) .board__column-body {
    display: flex !important;
    overflow-y: auto;
  }

  /* No internal scroll for a collapsible column: <details> puts its
     content in a slot we cannot make a flex item portably, so a
     max-height on the element clips rather than scrolls. Folding is
     already this column's answer to being too long. */
  .board__column--collapsible {
    max-height: none;
  }
}

/* ---------------------------------------------------------------- */
/* Mobile: stacked collapsible card groups                            */
/* ---------------------------------------------------------------- */

@media (max-width: 767px) {
  .board {
    flex-direction: column;
    gap: 0.75rem;
  }

  /* Roomy tap target */
  .board__column-header {
    padding: 0;
  }

  .board__column-label,
  .board__column-summary {
    padding: 0.8rem 0.85rem;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
  }

  .board__column-label:active,
  .board__column-summary:active {
    background: color-mix(in oklab, var(--accent), transparent 40%);
    border-radius: var(--radius-lg);
  }

  .board__column:not(.board__column--collapsible) .board__column-body {
    display: none;
  }

  .board__toggle:checked ~ .board__column-body {
    display: flex;
    animation: board-body-in 0.2s ease;
  }

  .board__toggle:checked ~ .board__column-header .board__column-chevron {
    rotate: 180deg;
  }

  @keyframes board-body-in {
    from { opacity: 0; translate: 0 -0.25rem; }
    to { opacity: 1; translate: 0 0; }
  }
}

/* css/components/badge.css */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.1rem 0.6rem;
  border-radius: 999px;
  border: 1px solid transparent;
  font-size: var(--text-xs);
  font-weight: 500;
  line-height: 1.4;
  white-space: nowrap;
  background: var(--primary);
  color: var(--primary-foreground);
}

.badge--secondary {
  background: var(--secondary);
  color: var(--secondary-foreground);
}

.badge--destructive {
  background: var(--destructive);
  color: var(--destructive-foreground);
}

.badge--success {
  background: var(--success);
  color: var(--success-foreground);
}

.badge--outline {
  background: transparent;
  color: var(--foreground);
  border-color: var(--border);
}

/* css/components/chart.css */
/* Chart: a sized box around a responsive canvas. Chart.js measures its
   parent, so the height has to come from somewhere — the component sets
   it inline from ChartProps.Height. */

.chart {
  position: relative;
  width: 100%;
  min-width: 0;
}

.chart__canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* Until the fetch resolves the canvas is blank; a faint surface reads as
   "loading" rather than "broken". */
.chart:not(:has([data-chart-ready])) {
  background: color-mix(in oklab, var(--muted), transparent 55%);
  border-radius: var(--radius-md);
}

.chart:has([data-chart-error])::after {
  content: "Chart data unavailable";
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

/* css/components/choicegrid.css */
/* ChoiceGrid: radios as a grid of tiles. The native input stays in the
   DOM and focusable — it IS the control, and the tile is its skin — so
   arrow-key navigation, form submission and validation all work without
   a line of JavaScript. */

/* width: 100% so the tiles reflow across the row it is given. Inside a
   flex column that aligns children to the start (the docs preview, a
   form laid out as a Stack) a grid without an intrinsic width shrinks to
   one tile and stacks every option — a column of radios, not a grid. */
.choice-grid {
  border: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  min-width: 0;
  width: 100%;
}

.choice-grid__legend {
  padding: 0;
  font-size: var(--text-sm);
  font-weight: 500;
}

.choice-grid__group-label {
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin-top: 0.25rem;
}

.choice-grid__group {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
  gap: 0.5rem;
}

.choice-grid__item {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  padding: 0.55rem 0.7rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  cursor: pointer;
  background: var(--card);
  transition: border-color 0.12s ease, background-color 0.12s ease;
}

.choice-grid__item--wide {
  grid-column: 1 / -1;
}

/* Visually hidden but focusable: the tile shows the state, the input
   holds it. display:none would take it out of the tab order and break
   arrow-key navigation between radios. */
.choice-grid__input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.choice-grid__label {
  font-size: var(--text-sm);
  font-weight: 500;
}

.choice-grid__hint {
  font-size: var(--text-xs);
  color: var(--muted-foreground);
}

@media (hover: hover) {
  .choice-grid__item:hover {
    border-color: var(--ring);
  }
}

/* Focus lands on the hidden input, so the ring has to show on the tile —
   the only thing the user can see. */
.choice-grid__item:has(.choice-grid__input:focus-visible) {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

.choice-grid__item:has(.choice-grid__input:checked) {
  border-color: var(--primary);
  background: color-mix(in oklab, var(--primary), transparent 92%);
}

.choice-grid__item:has(.choice-grid__input:disabled) {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Domain variants tint the selected state only — an unselected grid
   should read as one set of options, not a colour chart. */
.choice-grid__item--success:has(.choice-grid__input:checked) {
  border-color: var(--success);
  background: color-mix(in oklab, var(--success), transparent 90%);
}

.choice-grid__item--destructive:has(.choice-grid__input:checked) {
  border-color: var(--destructive);
  background: color-mix(in oklab, var(--destructive), transparent 90%);
}

/* css/components/chip.css */
/* Chip: compact status pill, or a filter link when it carries a URL.
   Variant is open — consumers add chip--<their-domain> alongside these. */

.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.15rem 0.55rem;
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  background: var(--muted);
  color: var(--foreground);
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: 1.5;
  white-space: nowrap;
}

.chip__count {
  font-variant-numeric: tabular-nums;
  font-weight: 500;
  opacity: 0.7;
}

/* Semantic variants. Tinted rather than filled: chips sit in dense rows
   and full-strength fills would fight everything around them.

   Text is the semantic hue mixed toward the theme's foreground — the
   raw tokens are tuned as fills behind white text and are too light to
   read as text on a pale tint (2.8:1 in the light theme). Mixing toward
   the foreground darkens in light and lightens in dark, so a single
   declaration reaches AA in both. Copy this shape for domain variants. */

.chip--secondary {
  background: var(--secondary);
  color: var(--secondary-foreground);
}

.chip--success {
  background: color-mix(in oklab, var(--success), transparent 85%);
  color: color-mix(in oklab, var(--success), var(--foreground) 32%);
}

.chip--destructive {
  background: color-mix(in oklab, var(--destructive), transparent 85%);
  color: color-mix(in oklab, var(--destructive), var(--foreground) 32%);
}

.chip--muted {
  background: var(--muted);
  color: color-mix(in oklab, var(--muted-foreground), var(--foreground) 25%);
}

.chip--outline {
  background: transparent;
  color: var(--foreground);
  border-color: var(--border);
}

/* Filter-link chips. */

.chip--link {
  cursor: pointer;
  text-decoration: none;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}

@media (hover: hover) {
  .chip--link:hover:not(.chip--selected) {
    background: var(--accent);
    color: var(--accent-foreground);
  }
}

.chip--link:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

.chip--selected {
  background: var(--primary);
  color: var(--primary-foreground);
  border-color: var(--primary);
}

.chip--selected .chip__count {
  opacity: 0.85;
}

/* css/components/commandsnippet.css */
/* CommandSnippet: a shell command presented as something to copy.
   Scrolls sideways rather than wrapping — see the component's doc
   comment for why a wrapped command line is the worse failure. */

.command-snippet {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  width: 100%;
  min-width: 0;
}

.command-snippet__label {
  font-size: var(--text-xs);
  font-weight: 600;
  color: var(--muted-foreground);
}

.command-snippet__row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  /* Without this the flex item below refuses to shrink below its
     content width, and the "scrollbar" becomes the whole page scrolling
     sideways instead. */
  min-width: 0;
  padding: 0.3rem 0.3rem 0.3rem 0.7rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: color-mix(in oklab, var(--muted), transparent 25%);
}

.command-snippet__code {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  white-space: pre;
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  line-height: 1.8;
  color: var(--foreground);
  scrollbar-width: thin;
}

/* base.css gives every <code> a pill background of its own, which
   inside this block would be a box drawn inside a box. */
.command-snippet__code code {
  background: none;
  padding: 0;
  border-radius: 0;
  font-family: inherit;
  font-size: inherit;
}

.command-snippet__copy {
  flex: 0 0 auto;
  align-self: center;
}

/* The confirmation swaps the label in place rather than adding a
   message beside it: the feedback belongs on the control that was
   pressed, and a block that changes height on click makes the page
   jump under the pointer. */
.command-snippet__copy-done {
  display: none;
}

.command-snippet__copy[data-copied] .command-snippet__copy-idle {
  display: none;
}

.command-snippet__copy[data-copied] .command-snippet__copy-done {
  display: inline;
  color: var(--success);
}

/* css/components/confirmdanger.css */
/* ConfirmDanger: typed-phrase guard before an irreversible action. The
   disabled state is the whole visual — it has to read as "not yet",
   not as "broken". */

.confirm-danger {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  align-items: flex-start;
}

.confirm-danger__submit {
  align-self: flex-start;
}

/* The phrase input is a place to be careful, not fast: monospace so the
   user can see exactly what they typed against what was asked. */
.confirm-danger [data-confirm-input] {
  font-family: var(--font-mono);
}

/* css/components/copybutton.css */
/* CopyButton: a Button that writes a value to the clipboard, wrapped
   with the live region ds.js announces through. The state classes below
   are added by ds.js, never rendered by the server — a page reloaded
   mid-confirmation should come back idle. */

.copy-button {
  display: inline-flex;
  align-items: center;
}

/* State tints, applied over whichever Button variant the caller chose.
   Overriding the variant's fill is the point: for the two seconds it
   lasts, "this just happened" is more useful than the resting style.

   Tinted rather than filled, with the text mixed toward the foreground —
   the raw semantic tokens are tuned as fills behind white text and are
   too light to read as text on a pale tint. Same recipe as Chip, and it
   reaches AA in both themes for the same reason.

   Never the only signal: the label itself reads Copied or Copy failed.
   And box-shadow is deliberately untouched — .btn:focus-visible draws
   its ring with it, and someone who copied by pressing Enter must not
   lose the focus ring for the two seconds afterwards. */

.copy-button--copied > .btn {
  background: color-mix(in oklab, var(--success), transparent 85%);
  color: color-mix(in oklab, var(--success), var(--foreground) 32%);
  border-color: color-mix(in oklab, var(--success), transparent 55%);
}

.copy-button--failed > .btn {
  background: color-mix(in oklab, var(--destructive), transparent 85%);
  color: color-mix(in oklab, var(--destructive), var(--foreground) 32%);
  border-color: color-mix(in oklab, var(--destructive), transparent 55%);
}

/* .btn:hover fades to 0.9 opacity, which over a pale tint reads as the
   confirmation dissolving early under the pointer that caused it. */
.copy-button--copied > .btn:hover,
.copy-button--failed > .btn:hover {
  opacity: 1;
}

/* css/components/stat.css */
/* Stat: KPI contents — label, big value, delta pill, hint. No card
   chrome; compose inside a Card when you want one. */

.stat {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  min-width: 0;
  /* Each Stat measures its own width, so the value's size can answer to
     the space it actually got. Grid consumers give stats minmax(0, 1fr)
     columns, which happily shrink below the value's width — without this,
     a long figure just walks over its neighbour. */
  container-type: inline-size;
  /* Size containment zeroes the intrinsic width, so a shrink-to-fit
     ancestor (flex-start column, width: max-content) collapses the tile
     to nothing. Advertise a footprint instead: one size-class above the
     first query threshold, so a content-sized context renders the value
     at full size. Definite contexts (minmax(0, 1fr) tracks) ignore this
     and can still shrink the tile, which the queries below absorb. */
  contain-intrinsic-inline-size: 14em;
}

.stat__label {
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

.stat__row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.5rem;
  min-width: 0;
}

.stat__value {
  font-size: var(--text-2xl);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.025em;
  font-variant-numeric: tabular-nums;
  /* Last resort when even the smallest step cannot fit: wrap inside the
     figure rather than overlap the neighbouring stat. A broken-looking
     wrap is legible; two figures printed over each other are not. */
  overflow-wrap: anywhere;
}

/* The value steps down as its stat narrows. Thresholds come from the
   figures this component actually shows: a 13-char "$1,620,405.17" needs
   ~11.5em of tabular digits at any size, so each step buys roughly a
   size-class of headroom before the wrap fallback has to act. */
@container (max-width: 13em) {
  .stat__value {
    font-size: var(--text-xl);
  }
}

@container (max-width: 10.5em) {
  .stat__value {
    font-size: var(--text-lg);
  }
}

@container (max-width: 8.5em) {
  .stat__value {
    font-size: var(--text-base);
  }
}

.stat__delta {
  display: inline-flex;
  align-items: center;
  padding: 0.1rem 0.5rem;
  border-radius: 999px;
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: 1.4;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  background: var(--muted);
  color: color-mix(in oklab, var(--muted-foreground), var(--foreground) 25%);
}

.stat__hint {
  font-size: var(--text-xs);
  color: var(--muted-foreground);
}

/* Trend tints the value and the delta together — the value is assumed
   to be the signed change itself.

   Text is the semantic hue mixed toward the theme's own foreground,
   which is what carries these to WCAG AA: in light that darkens the
   colour against a pale tint, in dark it lightens it against a deep one,
   so one expression is correct in both themes. The raw tokens sit at a
   lightness tuned for use as FILLS behind white text, and land at
   2.9-4.4:1 as text — below AA for the 12px delta. Delta text is small,
   so it is the binding constraint; the value inherits the same treatment
   so that Stat stays legible if a consumer renders it smaller, where
   large-text AA (3:1) would no longer apply. */

.stat--up .stat__value,
.stat--up .stat__delta {
  color: color-mix(in oklab, var(--success), var(--foreground) 32%);
}

.stat--up .stat__delta {
  background: color-mix(in oklab, var(--success), transparent 88%);
}

.stat--down .stat__value,
.stat--down .stat__delta {
  color: color-mix(in oklab, var(--destructive), var(--foreground) 32%);
}

.stat--down .stat__delta {
  background: color-mix(in oklab, var(--destructive), transparent 88%);
}

.stat--flat .stat__value {
  color: var(--muted-foreground);
}

/* css/components/multiselect.css */
/* MultiSelect: token-input multi picker. Tags live inside the field
   box; suggestions float in a panel under it, so the page never jumps
   while the user types. */

.multiselect {
  /* The panel anchors to the box, not the viewport, so the control can
     sit inside cards, dialogs and forms without escaping them. */
  position: relative;
  width: 100%;
}

/* The box mirrors .input's chrome, but grows with its tags: min-height
   where Input fixes height, and wrap where Input is a single line. */
.multiselect__control {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3rem;
  min-height: 2.25rem;
  padding: 0.25rem 0.5rem;
  border: 1px solid var(--input);
  border-radius: var(--radius-md);
  background: transparent;
  box-shadow: var(--shadow-sm);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.multiselect__control:hover:not(.multiselect__control--disabled):not(:focus-within) {
  border-color: color-mix(in oklab, var(--input), var(--ring) 45%);
}

/* The ring belongs to the box, not the borderless input inside it —
   the whole control is the focus target the user perceives. */
.multiselect__control:focus-within {
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 70%);
}

.multiselect__control--disabled {
  opacity: 0.5;
  pointer-events: none;
}

.multiselect__control:has(.multiselect__input[aria-invalid="true"]) {
  border-color: var(--destructive);
}

.multiselect__control:has(.multiselect__input[aria-invalid="true"]):focus-within {
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--destructive), transparent 70%);
}

/* The input is chromeless — the box carries the field appearance. flex:1
   with a small basis lets it share a row with tags and still take every
   remaining pixel. */
.multiselect__input {
  flex: 1 1 6rem;
  min-width: 0;
  height: 1.6rem;
  border: none;
  outline: none;
  background: transparent;
  font-size: var(--text-sm);
  color: var(--foreground);
}

.multiselect__input::placeholder {
  color: var(--muted-foreground);
}

/* Tags borrow Chip's tint but stay quieter: normal weight and a tighter
   radius, so a full box reads as content, not as a row of buttons. */
.multiselect__tag {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  max-width: 100%;
  padding: 0.1rem 0.3rem 0.1rem 0.5rem;
  border-radius: var(--radius-sm);
  background: var(--muted);
  color: var(--foreground);
  font-size: var(--text-xs);
  font-weight: 500;
  line-height: 1.5;
}

/* A tag without a remove button (static render) keeps even padding. */
.multiselect__tag:not(:has(.multiselect__tag-remove)) {
  padding-right: 0.5rem;
}

.multiselect__tag-remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1rem;
  height: 1rem;
  padding: 0;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--muted-foreground);
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease;
}

.multiselect__tag-remove svg {
  width: 0.7rem;
  height: 0.7rem;
}

@media (hover: hover) {
  .multiselect__tag-remove:hover {
    background: color-mix(in oklab, var(--destructive), transparent 85%);
    color: color-mix(in oklab, var(--destructive), var(--foreground) 32%);
  }
}

.multiselect__tag-remove:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 1px;
}

.multiselect__hint {
  margin-top: 0.3rem;
  font-size: var(--text-xs);
  color: var(--muted-foreground);
  font-variant-numeric: tabular-nums;
}

.multiselect__hint--unmet {
  color: color-mix(in oklab, var(--destructive), var(--foreground) 32%);
}

/* The panel: same surface family as Dropdown, anchored to the box in
   flow-relative terms. It overlays instead of pushing content, which is
   what keeps a form from pumping while the user types. */
.multiselect__list {
  position: absolute;
  z-index: 30;
  top: calc(100% + 0.25rem);
  left: 0;
  right: 0;
  max-height: 16rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 0.25rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--popover);
  color: var(--popover-foreground);
  box-shadow: var(--shadow-md);
  animation: multiselect-in 0.12s ease;
}

/* hidden must win over the block above. */
.multiselect__list[hidden] {
  display: none;
}

/* Light dismiss, in CSS: the panel shows only while the control holds
   focus. Clicking anywhere outside the control hides an open panel
   without a round trip; a pick clears it for real (the control swaps
   fresh with an empty query). */
.multiselect:not(:focus-within) .multiselect__list {
  display: none;
}

@keyframes multiselect-in {
  from { opacity: 0; translate: 0 -0.25rem; }
  to { opacity: 1; translate: 0 0; }
}

.multiselect__option {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  padding: 0.4rem 0.6rem;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: inherit;
  font-size: var(--text-sm);
  text-align: left;
  cursor: pointer;
}

@media (hover: hover) {
  .multiselect__option:hover {
    background: var(--accent);
    color: var(--accent-foreground);
  }
}

.multiselect__option:focus-visible {
  outline: none;
  background: var(--accent);
  color: var(--accent-foreground);
}

.multiselect__option svg {
  width: 0.85rem;
  height: 0.85rem;
  flex-shrink: 0;
}

/* The match is emphasis, not a highlighter — <mark>'s UA yellow would
   shout over the theme. */
.multiselect__match {
  background: transparent;
  color: inherit;
  font-weight: 700;
}

.multiselect__option--create {
  color: color-mix(in oklab, var(--primary), var(--foreground) 20%);
}

.multiselect__create-term {
  font-weight: 600;
}

.multiselect__empty {
  padding: 0.4rem 0.6rem;
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

/* css/components/emptystate.css */
/* EmptyState: centred "nothing here yet" panel with room for a CTA. */

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* It stands in for a region's content, so it claims the region's
     width even inside a flex parent that would shrink it. */
  width: 100%;
  gap: 0.5rem;
  padding: 3rem 1.5rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--card);
  color: var(--card-foreground);
  text-align: center;
}

.empty-state__icon {
  display: inline-flex;
  font-size: 2.5rem;
  line-height: 1;
  color: var(--muted-foreground);
  opacity: 0.6;
  margin-bottom: 0.25rem;
}

.empty-state__title {
  font-size: var(--text-lg);
  font-weight: 600;
  letter-spacing: -0.015em;
}

.empty-state__description {
  max-width: 42ch;
  color: var(--muted-foreground);
  font-size: var(--text-sm);
}

/* Breathing room before a CTA, without assuming one is there. */
.empty-state > :not(.empty-state__icon, .empty-state__title, .empty-state__description) {
  margin-top: 0.75rem;
}

/* css/components/spinner.css */
/* Spinner: a rotating arc. Sized in em so it inherits the surrounding
   font-size — inside a button it matches the label automatically.

   The loading-state pattern needs no CSS here: htmx installs its own
   .htmx-indicator rules through adoptedStyleSheets — hidden by default,
   revealed both as a descendant of .htmx-request and on it — and marks
   the requesting element .htmx-request, which base.css dims and makes
   pointer-events: none. Give the Spinner class "htmx-indicator" and the
   button handles its own loading state. htmx's reveal fades in over
   200ms, so the spinner is not fully opaque on the very first frame. */

.spinner {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  animation: spinner-spin 0.7s linear infinite;
}

.spinner--sm {
  font-size: 0.85em;
}

.spinner--lg {
  font-size: 1.5em;
}

@keyframes spinner-spin {
  to { rotate: 360deg; }
}

/* A frozen spinner reads as a hung page, so this is the one animation
   that keeps running under reduced motion — slowed, not stopped.
   base.css kills animations with !important, hence the override. */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation-duration: 1.5s !important;
    animation-iteration-count: infinite !important;
  }
}

/* css/components/alert.css */
.alert {
  width: 100%;
  display: flex;
  align-items: flex-start;
  gap: 0.65rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 0.85rem 1rem;
  background: var(--card);
  color: var(--card-foreground);
}

.alert__icon {
  display: inline-flex;
  margin-top: 0.1rem;
  font-size: 1rem;
  color: var(--muted-foreground);
}

.alert__content {
  display: grid;
  gap: 0.25rem;
  min-width: 0;
}

.alert__title {
  font-weight: 600;
  font-size: var(--text-sm);
  line-height: 1.3;
}

.alert__description {
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

.alert--destructive {
  border-color: color-mix(in oklab, var(--destructive), transparent 60%);
  background: color-mix(in oklab, var(--destructive), var(--card) 94%);
}

.alert--destructive .alert__icon,
.alert--destructive .alert__title {
  color: var(--destructive);
}

.alert--destructive .alert__description {
  color: color-mix(in oklab, var(--destructive), var(--foreground) 25%);
}

.alert--success {
  border-color: color-mix(in oklab, var(--success), transparent 60%);
  background: color-mix(in oklab, var(--success), var(--card) 94%);
}

.alert--success .alert__icon,
.alert--success .alert__title {
  color: var(--success);
}

.alert--success .alert__description {
  color: color-mix(in oklab, var(--success), var(--foreground) 25%);
}

/* css/components/table.css */
.table-wrap {
  width: 100%;
  overflow-x: auto;
  /* Containing block for absolutely positioned descendants — which is
     what .visually-hidden is. Without it, a screen-reader-only column
     heading in a table wider than the viewport is positioned against
     the INITIAL containing block at its static position, i.e. hundreds
     of pixels to the right, and escapes this element's clip entirely.
     The element stays invisible; the DOCUMENT grows to reach it and the
     whole page scrolls sideways. Measured at 375px: the page grew to
     715px from one visually-hidden span. */
  position: relative;
}

.table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-sm);
  caption-side: bottom;
  font-variant-numeric: tabular-nums;
}

.table__caption {
  margin-top: 1rem;
  color: var(--muted-foreground);
  font-size: var(--text-sm);
  text-align: left;
}

.table__row {
  border-bottom: 1px solid var(--border);
  transition: background-color 0.1s ease;
}

.table__body .table__row:hover {
  background: color-mix(in oklab, var(--muted), transparent 50%);
}

.table__head {
  height: 2.5rem;
  padding: 0 0.75rem;
  text-align: left;
  vertical-align: middle;
  font-weight: 500;
  color: var(--muted-foreground);
  white-space: nowrap;
}

.table__cell {
  padding: 0.6rem 0.75rem;
  vertical-align: middle;
}

.table__sort {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  text-decoration: none;
  color: inherit;
  border-radius: var(--radius-sm);
}

.table__sort:hover,
.table__sort--active {
  color: var(--foreground);
}

.table__sort-indicator {
  display: inline-flex;
  font-size: 0.8em;
  opacity: 0.5;
  transition: opacity 0.12s ease;
}

.table__sort:hover .table__sort-indicator,
.table__sort--active .table__sort-indicator {
  opacity: 1;
}

/* Stacked mode: below the breakpoint each row becomes a labelled card,
   so a wide table stays readable on a phone without horizontal scroll.
   Add table--stack to the Table and data-label="…" to every cell — the
   label is what replaces the header row, which is hidden because its
   column positions no longer mean anything.

   Not for every table. A grid the user reads ACROSS — comparing many
   numeric columns row by row — loses that comparison when it stacks;
   those want the scroll container instead. Use it for lists of records
   the user reads one at a time. */

@media (max-width: 767px) {
  .table--stack,
  .table--stack .table__body,
  .table--stack .table__row,
  .table--stack .table__cell {
    display: block;
    width: 100%;
  }

  .table--stack .table__header {
    /* Not display:none — that would drop the headers from the
       accessibility tree along with the layout. */
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .table--stack .table__row {
    padding: 0.35rem 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    margin-bottom: 0.75rem;
  }

  .table--stack .table__row:last-child {
    margin-bottom: 0;
  }

  /* Inside a Card the rows stop being boxes. The card already draws the
     frame, and a bordered row inside a bordered card is two borders
     colliding a few pixels apart — so the rows become a divided list
     instead: one hairline between neighbours, the card's edge doing the
     rest. Automatic on purpose: every consumer who puts a stacked table
     in a card would otherwise rediscover the double border and fix it
     locally. */
  .card .table--stack .table__row {
    border: 0;
    border-radius: 0;
    margin-bottom: 0;
    border-top: 1px solid var(--border);
  }

  .card .table--stack .table__row:first-child {
    border-top: 0;
  }

  .table--stack .table__cell {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.35rem 0.75rem;
    border: 0;
    text-align: right;
  }

  /* The label stands in for the hidden column header. A cell with no
     data-label spans the full width — right for a row's actions. */
  .table--stack .table__cell[data-label]::before {
    content: attr(data-label);
    flex: 0 0 auto;
    margin-right: auto;
    font-size: var(--text-xs);
    font-weight: 500;
    color: var(--muted-foreground);
    text-align: left;
  }

  .table--stack .table__caption {
    display: block;
  }

  /* Curated stacked mode: table--curated on top of table--stack. Cells
     carrying table__cell--lead are the row's face — what identifies it
     and what it is worth — laid out on one line; every other cell hides
     behind the row's TableRowDisclosure until its checkbox opens it.
     On a phone every extra always-visible line is paid for by the row
     below it, so the face carries two or three values and the rest is
     a tap away, exactly like a details/summary card — which a <tr>
     cannot legally contain, hence the checkbox.

     :has() is scoped to the row's own descendant checkbox; rows do not
     nest, so the descendant form is safe here (unlike the AppShell). */

  /* The face is a small grid, which is what keeps its shape no matter
     how wide the figures run: the identity cell fills the left and
     spans both face rows, the figure leads stack on the right — value
     over return, the old card's block — and the chevron sits pinned in
     its own column. A wrapping flex row put wide and narrow figures on
     different lines row by row; a grid cannot. */
  .table--curated.table--stack .table__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto auto;
    align-items: center;
    /* Containing block for the closed row's whole-face tap target. */
    position: relative;
  }

  /* While the row is closed, the entire face is the toggle — the label
     grows an overlay covering the row, which is what the old-world
     details/summary gave for free. Once open, the overlay retracts so
     the links and controls the fold reveals are tappable, and the
     chevron alone closes it. */
  .table--curated.table--stack
    .table__row:not(:has(.table__disclosure-input:checked))
    .table__disclosure-toggle::before {
    content: "";
    position: absolute;
    inset: 0;
    cursor: pointer;
  }

  /* A span marked data-fold inside a lead cell belongs to the fold:
     hidden while the row is closed, back when it opens. It is what
     lets a cost-over-value pair show only the value on the face. */
  .table--curated.table--stack
    .table__row:not(:has(.table__disclosure-input:checked))
    .table__cell--lead [data-fold] {
    display: none;
  }

  /* A fold-only cell exists for the opened row alone — a full-width
     call to action, say. Never rendered on desktop (same reasoning as
     the disclosure cell: the header has no column for it). Its order
     sorts it after the detail cells, at the bottom of the fold. */
  .table--curated.table--stack
    .table__row:has(.table__disclosure-input:checked)
    .table__cell--fold-only {
    display: flex;
    grid-column: 1 / -1;
    order: 11;
  }

  .table--curated.table--stack .table__cell--lead {
    display: flex;
    width: auto;
    align-items: baseline;
    justify-content: flex-end;
    gap: 0.5rem;
    grid-column: 2;
    padding-block: 0.15rem;
  }

  /* The first lead cell is the row's identity: it fills the left column
     and spans the face's rows, so however many figure leads stack beside
     it, the face reads name … figures … chevron. text-align comes back
     left explicitly — the stacked base aligns cells right for figures,
     and inherited into the identity it right-aligned a short symbol
     against its own longer sub-line. A name reads from the left. */
  .table--curated.table--stack .table__cell--lead:first-child {
    min-width: 0;
    justify-content: flex-start;
    text-align: left;
    grid-column: 1;
    grid-row: 1 / span 2;
  }

  /* The face does not label itself: a symbol and a bold figure name
     themselves, and a labelled face is just the table again. Labels
     return on the detail cells, where a bare number would be a mystery. */
  .table--curated.table--stack .table__cell--lead[data-label]::before {
    content: none;
  }

  .table--curated.table--stack .table__cell:not(.table__cell--lead):not(.table__disclosure) {
    display: none;
    /* Detail cells sort after the face, so the fold flows below it
       instead of interleaving with the figure stack. */
    order: 10;
  }

  .table--curated.table--stack .table__row:has(.table__disclosure-input:checked)
    .table__cell:not(.table__cell--lead):not(.table__disclosure) {
    display: flex;
    grid-column: 1 / -1;
  }

  .table--curated.table--stack .table__disclosure {
    display: flex;
    width: auto;
    align-items: center;
    padding: 0.35rem 0.75rem;
    grid-column: 3;
    grid-row: 1 / span 2;
  }

  .table--curated.table--stack .table__disclosure-icon {
    display: inline-flex;
    color: var(--muted-foreground);
    transition: rotate 0.15s ease;
  }

  .table--curated.table--stack .table__row:has(.table__disclosure-input:checked)
    .table__disclosure-icon {
    rotate: 180deg;
  }
}

/* The disclosure cell exists only for the stacked face. At desktop
   widths the whole row is visible and the header has no column for it,
   so it must not render — an extra td would shear every column over.
   Fold-only cells follow the same rule for the same reason. */
.table__disclosure,
.table__cell--fold-only {
  display: none;
}

/* The checkbox is the state: visually hidden but focusable, the same
   shape as the AppShell toggles, so Space works and the focus ring
   lands on the visible chevron. */
.table__disclosure-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.table__disclosure-toggle {
  display: inline-flex;
  align-items: center;
  cursor: pointer;
}

.table__disclosure-toggle:has(.table__disclosure-input:focus-visible) {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* css/components/avatar.css */
.avatar {
  display: inline-flex;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 50%;
  overflow: hidden;
  flex-shrink: 0;
  background: var(--muted);
  align-items: center;
  justify-content: center;
}

.avatar--sm {
  width: 2rem;
  height: 2rem;
}

.avatar--lg {
  width: 3.5rem;
  height: 3.5rem;
}

.avatar__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.avatar__fallback {
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--muted-foreground);
  user-select: none;
}

.avatar--lg .avatar__fallback {
  font-size: var(--text-base);
}

/* css/components/misc.css */
/* Separator */

.separator {
  background: var(--border);
  height: 1px;
  width: 100%;
  flex-shrink: 0;
}

.separator--vertical {
  height: auto;
  width: 1px;
  align-self: stretch;
}

/* Skeleton: shimmer sweep */

.skeleton {
  position: relative;
  overflow: hidden;
  background: var(--muted);
  border-radius: var(--radius-md);
}

.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  translate: -100% 0;
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in oklab, var(--foreground), transparent 92%),
    transparent
  );
  animation: skeleton-shimmer 1.8s ease-in-out infinite;
}

@keyframes skeleton-shimmer {
  to { translate: 100% 0; }
}

/* Progress */

.progress {
  width: 100%;
  height: 0.5rem;
  border-radius: 999px;
  background: color-mix(in oklab, var(--primary), transparent 80%);
  overflow: hidden;
}

.progress__bar {
  height: 100%;
  background: var(--primary);
  border-radius: inherit;
  transition: width 0.4s ease;
}

/* Breadcrumb */

.breadcrumb__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

.breadcrumb__item {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}

.breadcrumb__link {
  text-decoration: none;
  transition: color 0.15s ease;
}

.breadcrumb__link:hover {
  color: var(--foreground);
}

.breadcrumb__current {
  color: var(--foreground);
  font-weight: 500;
}

.breadcrumb__separator {
  user-select: none;
  display: inline-flex;
  font-size: 0.8em;
  color: color-mix(in oklab, var(--muted-foreground), transparent 30%);
}

/* css/components/tabs.css */
.tabs {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  width: 100%;
}

.tabs__list {
  display: inline-flex;
  align-self: flex-start;
  align-items: center;
  gap: 0.25rem;
  padding: 0.25rem;
  border-radius: var(--radius-lg);
  background: var(--muted);
}

.tabs__trigger {
  padding: 0.3rem 0.9rem;
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--muted-foreground);
  transition: background-color 0.15s ease, color 0.15s ease;
}

/* Hover-capable devices only — avoids sticky :hover on touch. */
@media (hover: hover) {
  .tabs__trigger:hover:not(.tabs__trigger--active) {
    color: var(--foreground);
  }
}

.tabs__trigger--active {
  background: var(--background);
  color: var(--foreground);
  box-shadow: var(--shadow-sm);
}

/* Segmented: the denser button-group look for period/mode/range
   switches. Same server-state semantics, tighter metrics, and no panel
   gap — segmented controls usually drive a region elsewhere on the page
   rather than a panel underneath. */

.tabs--segmented {
  gap: 0;
  width: auto;
}

.tabs--segmented .tabs__list {
  gap: 0.125rem;
  padding: 0.2rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  flex-wrap: wrap;
}

.tabs--segmented .tabs__trigger {
  padding: 0.2rem 0.7rem;
  border-radius: var(--radius-sm);
  font-size: var(--text-xs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.tabs--segmented .tabs__trigger--active {
  background: var(--primary);
  color: var(--primary-foreground);
  box-shadow: none;
}

/* An empty panel collapses. Tabs are also used as a plain switcher, with
   the panes they switch between laid out elsewhere (side by side on a wide
   screen, where they cannot live inside one tab's panel) — and then a
   min-height panel is a band of nothing under the switcher. The docs have
   always said this collapses; only the segmented variant actually did. */
.tabs__panel:empty {
  display: none;
}

.tabs__panel {
  min-height: 3rem;
  animation: tab-panel-in 0.2s ease;
}

@keyframes tab-panel-in {
  from { opacity: 0; translate: 0 0.2rem; }
  to { opacity: 1; translate: 0 0; }
}

/* css/components/tabpanel.css */
/* Responsive tab panel: strip on top (desktop), floating bottom menu
   (mobile). One markup, media queries decide. */

.tabpanel {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  width: 100%;
}

.tabpanel__nav {
  display: inline-flex;
  align-self: flex-start;
  align-items: center;
  gap: 0.25rem;
  padding: 0.25rem;
  border-radius: var(--radius-lg);
  background: var(--muted);
}

.tabpanel__trigger {
  padding: 0.3rem 0.9rem;
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--muted-foreground);
  white-space: nowrap;
  transition: background-color 0.15s ease, color 0.15s ease;
}

/* Hover only on devices that hover: on touch, taps leave a sticky
   :hover that would otherwise override the active pill's text color. */
@media (hover: hover) {
  .tabpanel__trigger:hover:not(.tabpanel__trigger--active) {
    color: var(--foreground);
  }
}

.tabpanel__trigger--active {
  background: var(--background);
  color: var(--foreground);
  box-shadow: var(--shadow-sm);
}

.tabpanel__panel {
  animation: tab-panel-in 0.2s ease;
}

@media (max-width: 767px) {
  /* Floating pill menu pinned to the bottom of the *visual* viewport.
     position: fixed tracks the visual viewport when the mobile address
     bar collapses/expands; max() keeps it above the home-indicator via
     the safe-area inset (requires viewport-fit=cover). */
  .tabpanel__nav {
    position: fixed;
    left: 50%;
    translate: -50% 0;
    bottom: max(0.75rem, env(safe-area-inset-bottom));
    z-index: 40;
    align-self: auto;
    max-width: calc(100vw - 1.5rem);
    overflow-x: auto;
    background: color-mix(in oklab, var(--popover), transparent 8%);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border: 1px solid var(--border);
    border-radius: 999px;
    box-shadow: var(--shadow-lg);
    padding: 0.3rem;
  }

  .tabpanel__trigger {
    border-radius: 999px;
    padding: 0.45rem 1rem;
  }

  .tabpanel__trigger--active {
    background: var(--primary);
    color: var(--primary-foreground);
  }

  /* Keep panel content from hiding behind the floating menu. */
  .tabpanel__panel {
    padding-bottom: 4.5rem;
  }
}

/* css/components/accordion.css */
.accordion {
  width: 100%;
  display: flex;
  flex-direction: column;
}

.accordion__item {
  border-bottom: 1px solid var(--border);
}

.accordion__trigger {
  display: flex;
  width: 100%;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1rem 0;
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--foreground);
  text-align: left;
}

.accordion__trigger:hover {
  text-decoration: underline;
  text-underline-offset: 3px;
}

.accordion__chevron {
  display: inline-flex;
  font-size: 0.9em;
  transition: rotate 0.2s ease;
  color: var(--muted-foreground);
}

.accordion__item--open .accordion__chevron {
  rotate: 180deg;
}

.accordion__content {
  padding-bottom: 1rem;
  font-size: var(--text-sm);
  color: var(--muted-foreground);
  animation: accordion-down 0.2s ease;
}

@keyframes accordion-down {
  from { opacity: 0; translate: 0 -0.25rem; }
  to { opacity: 1; translate: 0 0; }
}

/* css/components/pagination.css */
.pagination {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  flex-wrap: wrap;
}

.pagination__link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.25rem;
  height: 2.25rem;
  padding: 0 0.6rem;
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
  text-decoration: none;
  color: var(--foreground);
  border: 1px solid transparent;
  transition: background-color 0.15s ease;
}

.pagination__link:hover {
  background: var(--accent);
}

.pagination__link--current {
  border-color: var(--border);
  font-weight: 500;
}

.pagination__link--disabled {
  opacity: 0.5;
  pointer-events: none;
}

.pagination__ellipsis {
  padding: 0 0.4rem;
  color: var(--muted-foreground);
}

/* Phones: a seven-link window (prev, five pages, next) is 20rem of
   36px targets — wider than a card at 390px, so Next wrapped onto its
   own line. Tighter targets keep the window on one row; 32px is still
   inside the touch-size guidance for controls this close to each other. */
@media (max-width: 767px) {
  .pagination__link {
    min-width: 2rem;
    height: 2rem;
    padding: 0 0.4rem;
  }
}

/* css/components/toast.css */
.toast-region {
  position: fixed;
  bottom: max(1rem, env(safe-area-inset-bottom));
  right: 1rem;
  z-index: 100;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  width: min(22rem, calc(100vw - 2rem));
}

.toast {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: 0.65rem;
  padding: 0.85rem 1rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--popover);
  color: var(--popover-foreground);
  box-shadow: var(--shadow-lg);
  overflow: hidden;
  animation: toast-in 0.3s cubic-bezier(0.21, 1.02, 0.73, 1);
  transition: opacity 0.25s ease, translate 0.25s ease;
}

@keyframes toast-in {
  from { opacity: 0; translate: 0 0.75rem; scale: 0.97; }
  to { opacity: 1; translate: 0 0; scale: 1; }
}

/* htmx adds htmx-swapping during the swap:delay — this is the exit. */
.toast.htmx-swapping {
  opacity: 0;
  translate: 0.75rem 0;
}

.toast__icon {
  display: inline-flex;
  margin-top: 0.1rem;
  font-size: 1rem;
  color: var(--muted-foreground);
}

.toast--success .toast__icon { color: var(--success); }
.toast--destructive .toast__icon { color: var(--destructive); }

.toast__body {
  display: grid;
  gap: 0.15rem;
  flex: 1;
  min-width: 0;
}

.toast__title {
  font-size: var(--text-sm);
  font-weight: 600;
  line-height: 1.3;
}

.toast__description {
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

.toast__close {
  color: var(--muted-foreground);
  font-size: 0.8rem;
  padding: 0.2rem;
  border-radius: var(--radius-sm);
  flex-shrink: 0;
  transition: color 0.12s ease, background-color 0.12s ease;
}

.toast__close:hover {
  color: var(--foreground);
  background: var(--accent);
}

/* Countdown bar mirrors the auto-dismiss timer (--toast-duration). */
.toast__countdown {
  position: absolute;
  left: 0;
  bottom: 0;
  height: 2px;
  background: color-mix(in oklab, var(--foreground), transparent 75%);
  animation: toast-countdown var(--toast-duration, 5s) linear forwards;
}

.toast--success .toast__countdown { background: color-mix(in oklab, var(--success), transparent 40%); }
.toast--destructive .toast__countdown { background: color-mix(in oklab, var(--destructive), transparent 40%); }

@keyframes toast-countdown {
  from { width: 100%; }
  to { width: 0%; }
}

/* css/components/dialog.css */
.dialog__overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: var(--overlay);
  animation: dialog-fade-in 0.15s ease;
}

.dialog__panel {
  position: fixed;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  z-index: 51;
  width: min(28rem, calc(100vw - 2rem));
  max-height: min(85dvh, 40rem);
  overflow-y: auto;
  background: var(--card);
  color: var(--card-foreground);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-lg);
  padding: 1.5rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  animation: dialog-pop-in 0.15s ease;
}

@keyframes dialog-fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes dialog-pop-in {
  from { opacity: 0; scale: 0.96; }
  to { opacity: 1; scale: 1; }
}

/* On a phone the dialog is a bottom sheet, the same presentation the
   Dropdown already takes below this width: full-bleed, pinned to the
   bottom edge, square where it meets it. A floating centred card at
   390px reads as a misplaced desktop control — content peeks around
   every side of it — where the sheet reads as the phone idiom it is. */
@media (max-width: 767px) {
  .dialog__panel {
    top: auto;
    bottom: 0;
    left: 0;
    translate: none;
    width: 100vw;
    max-height: 92dvh;
    border-radius: var(--radius-xl) var(--radius-xl) 0 0;
    border-bottom: 0;
    padding-bottom: calc(1.5rem + env(safe-area-inset-bottom));
    animation: dialog-sheet-in 0.2s ease;
  }
}

@keyframes dialog-sheet-in {
  from { opacity: 0; translate: 0 1.5rem; }
  to { opacity: 1; translate: 0 0; }
}

/* Exit: htmx marks #modal-root with htmx-swapping during swap:delay. */
#modal-root.htmx-swapping .dialog__overlay,
#modal-root.htmx-swapping .dialog__panel {
  opacity: 0;
  transition: opacity 0.15s ease;
}

.dialog__header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
}

.dialog__title {
  font-size: var(--text-lg);
}

.dialog__description {
  margin-top: 0.25rem;
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

.dialog__close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  margin: -0.25rem -0.35rem 0 0;
  color: var(--muted-foreground);
  border-radius: var(--radius-sm);
  flex-shrink: 0;
  transition: color 0.12s ease, background-color 0.12s ease;
}

.dialog__close:hover {
  color: var(--foreground);
  background: var(--accent);
}

.dialog__footer {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
}

/* css/components/sidepanel.css */
.side-panel__overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: var(--overlay);
  animation: dialog-fade-in 0.25s ease;
}

.side-panel__panel {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 51;
  width: min(24rem, 90vw);
  background: var(--card);
  color: var(--card-foreground);
  border-left: 1px solid var(--border);
  box-shadow: var(--shadow-lg);
  padding: 1.5rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  overflow-y: auto;
  animation: side-panel-in-right 0.25s ease;
  transition: translate 0.25s ease;
}

.side-panel__panel--left {
  right: auto;
  left: 0;
  border-left: none;
  border-right: 1px solid var(--border);
  animation-name: side-panel-in-left;
}

@keyframes side-panel-in-right {
  from { translate: 100% 0; }
  to { translate: 0 0; }
}

@keyframes side-panel-in-left {
  from { translate: -100% 0; }
  to { translate: 0 0; }
}

/* Exit: slide back out while #panel-root carries htmx-swapping. */
#panel-root.htmx-swapping .side-panel__panel {
  translate: 100% 0;
}

#panel-root.htmx-swapping .side-panel__panel--left {
  translate: -100% 0;
}

#panel-root.htmx-swapping .side-panel__overlay {
  opacity: 0;
  transition: opacity 0.25s ease;
}

.side-panel__header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
}

.side-panel__title {
  font-size: var(--text-lg);
}

.side-panel__description {
  margin-top: 0.25rem;
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

.side-panel__close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  margin: -0.25rem -0.35rem 0 0;
  color: var(--muted-foreground);
  border-radius: var(--radius-sm);
  flex-shrink: 0;
  transition: color 0.12s ease, background-color 0.12s ease;
}

.side-panel__close:hover {
  color: var(--foreground);
  background: var(--accent);
}

.side-panel__content {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

/* css/components/dropdown.css */
.dropdown {
  /* Popover lives in the top layer; anchor positioning (where supported)
     pins it under its trigger. Fallback: UA default centers it. */
  position: fixed;
  margin: 0.25rem 0 0 0;
  inset: auto;
  top: anchor(bottom);
  left: anchor(left);
  min-width: 11rem;
  padding: 0.25rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--popover);
  color: var(--popover-foreground);
  box-shadow: var(--shadow-md);
  animation: dropdown-in 0.12s ease;
}

.dropdown--end {
  left: auto;
  right: anchor(right);
}

/* Browsers without anchor positioning ignore anchor() and fall back to
   these, centering the menu like a command palette. */
@supports not (top: anchor(bottom)) {
  .dropdown {
    inset: 0;
    margin: auto;
    height: fit-content;
  }
}

@keyframes dropdown-in {
  from { opacity: 0; translate: 0 -0.25rem; }
  to { opacity: 1; translate: 0 0; }
}

.dropdown::backdrop {
  background: transparent;
}

/* Mobile: an anchored floating menu detaches from its trigger when the
   page scrolls and can overflow small viewports. Below 768px the same
   markup presents as a bottom sheet: pinned to the visual viewport,
   dimmed backdrop, grab-handle affordance, larger touch targets, and
   internal scrolling for long menus. */
@media (max-width: 767px) {
  .dropdown,
  .dropdown--end {
    inset: auto 0 0 0;
    top: auto;
    left: 0;
    right: 0;
    width: 100%;
    min-width: 0;
    max-height: min(60dvh, 24rem);
    overflow-y: auto;
    overscroll-behavior: contain;
    margin: 0;
    border-radius: var(--radius-xl) var(--radius-xl) 0 0;
    border-bottom: none;
    padding: 1.4rem 0.6rem calc(0.6rem + env(safe-area-inset-bottom));
    box-shadow: var(--shadow-lg);
    animation: dropdown-sheet-in 0.25s cubic-bezier(0.32, 0.72, 0, 1);
  }

  /* Grab-handle */
  .dropdown::before {
    content: "";
    position: absolute;
    top: 0.5rem;
    left: 50%;
    translate: -50% 0;
    width: 2.5rem;
    height: 4px;
    border-radius: 999px;
    background: color-mix(in oklab, var(--muted-foreground), transparent 55%);
  }

  .dropdown::backdrop {
    background: var(--overlay);
  }

  .dropdown__item {
    padding: 0.7rem 0.8rem;
    font-size: var(--text-base);
    border-radius: var(--radius-md);
  }

  .dropdown__label {
    padding: 0.5rem 0.8rem;
  }

  @keyframes dropdown-sheet-in {
    from { translate: 0 100%; }
    to { translate: 0 0; }
  }
}

/* Items are buttons or links (DropdownItem / DropdownLink) — the
   declarations below cover both, so the two look identical. */
.dropdown__item {
  display: flex;
  width: 100%;
  align-items: center;
  gap: 0.5rem;
  padding: 0.4rem 0.6rem;
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  color: var(--popover-foreground);
}

.dropdown__item:hover,
.dropdown__item:focus-visible {
  background: var(--accent);
  color: var(--accent-foreground);
  outline: none;
}

.dropdown__label {
  padding: 0.4rem 0.6rem;
  font-size: var(--text-xs);
  font-weight: 600;
  color: var(--muted-foreground);
}

.dropdown__separator {
  height: 1px;
  margin: 0.25rem -0.25rem;
  background: var(--border);
}

/* css/components/themepicker.css */
/* ThemePicker: a Dropdown of theme families, each row previewed by a
   swatch drawn in that family's own tokens. The swatch works because a
   theme binds its tokens to [data-theme="<slug>"], which matches any
   element — so a span wearing the attribute renders in that theme. */

.theme-picker {
  display: inline-flex;
}

.theme-picker__trigger {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  height: 2.25rem;
  padding: 0 0.55rem 0 0.45rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--foreground);
  white-space: nowrap;
  transition: background-color 0.15s ease;
}

.theme-picker__trigger:hover {
  background: var(--accent);
}

.theme-picker__trigger:active {
  scale: 0.97;
}

.theme-picker__trigger > .icon {
  font-size: 0.8rem;
  color: var(--muted-foreground);
}

/* A phone header has no room for the family name; the swatch alone
   still says "theme" next to the sun/moon toggle. */
@media (max-width: 479px) {
  .theme-picker__label {
    display: none;
  }
}

.theme-picker__menu {
  min-width: 14rem;
  max-height: min(70dvh, 28rem);
  overflow-y: auto;
  overscroll-behavior: contain;
}

.theme-picker__item {
  gap: 0.6rem;
}

.theme-picker__item[aria-checked="true"] {
  font-weight: 500;
}

.theme-picker__item > .icon {
  font-size: 0.85rem;
  color: var(--muted-foreground);
}

.theme-picker__name {
  flex: 1;
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  min-width: 0;
}

.theme-picker__variant {
  font-size: var(--text-xs);
  font-weight: 400;
  color: var(--muted-foreground);
}

/* Swatch: the theme's background as a pill, its key colours as dots.
   Every colour here is a token, resolved inside the data-theme scope. */

.theme-swatch {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  flex-shrink: 0;
  height: 1.15rem;
  padding: 0 4px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--background);
}

.theme-swatch__dot {
  display: block;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
}

.theme-swatch__dot--foreground { background: var(--foreground); }
.theme-swatch__dot--primary { background: var(--primary); }
.theme-swatch__dot--success { background: var(--success); }
.theme-swatch__dot--destructive { background: var(--destructive); }

/* css/components/imageviewer.css */
/* Thumbnail grid */

.image-thumbs {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 0.75rem;
  width: 100%;
}

.image-thumbs__item {
  border-radius: var(--radius-lg);
  overflow: hidden;
  border: 1px solid var(--border);
  aspect-ratio: 4 / 3;
  padding: 0;
  transition: scale 0.15s ease, box-shadow 0.15s ease;
}

.image-thumbs__item:hover {
  scale: 1.02;
  box-shadow: var(--shadow-md);
}

.image-thumbs__item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Lightbox */

.lightbox__overlay {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: color-mix(in oklab, var(--overlay), black 40%);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  animation: dialog-fade-in 0.15s ease;
}

.lightbox__stage {
  position: fixed;
  inset: 0;
  z-index: 61;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  padding: 3rem 4.5rem;
  pointer-events: none;
  animation: dialog-pop-in 0.15s ease;
}

.lightbox__image {
  max-width: 100%;
  max-height: calc(100dvh - 8rem);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  pointer-events: auto;
}

.lightbox__caption {
  color: white;
  font-size: var(--text-sm);
  opacity: 0.85;
  pointer-events: auto;
}

#modal-root.htmx-swapping .lightbox__overlay,
#modal-root.htmx-swapping .lightbox__stage {
  opacity: 0;
  transition: opacity 0.15s ease;
}

.lightbox__close {
  position: fixed;
  top: max(1rem, env(safe-area-inset-top));
  right: 1rem;
  z-index: 62;
  color: white;
  font-size: var(--text-lg);
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 50%;
  background: oklch(1 0 0 / 0.1);
}

.lightbox__close:hover {
  background: oklch(1 0 0 / 0.2);
}

.lightbox__counter {
  position: fixed;
  top: max(1.4rem, env(safe-area-inset-top));
  left: 50%;
  translate: -50% 0;
  z-index: 62;
  color: white;
  font-size: var(--text-sm);
  opacity: 0.8;
}

.lightbox__nav {
  position: fixed;
  top: 50%;
  translate: 0 -50%;
  z-index: 62;
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  background: oklch(1 0 0 / 0.1);
  color: white;
  font-size: 1.4rem;
  line-height: 1;
  display: grid;
  place-content: center;
  transition: background-color 0.15s ease, scale 0.1s ease;
}

.lightbox__nav:hover {
  background: oklch(1 0 0 / 0.25);
}

.lightbox__nav:active {
  scale: 0.92;
}

.lightbox__counter {
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.05em;
  background: oklch(0 0 0 / 0.35);
  padding: 0.2rem 0.7rem;
  border-radius: 999px;
}

.lightbox__nav--prev { left: 1rem; }
.lightbox__nav--next { right: 1rem; }

@media (max-width: 767px) {
  .lightbox__stage { padding: 3rem 0.5rem 4rem; }
  .lightbox__nav { top: auto; bottom: max(1rem, env(safe-area-inset-bottom)); translate: 0 0; }
}

/* css/components/editor.css */
.editor {
  width: 100%;
  border: 1px solid var(--input);
  border-radius: var(--radius-lg);
  background: var(--card);
  box-shadow: var(--shadow-sm);
  overflow: hidden;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

/* Focus ring on the whole editor when writing. */
.editor:has(.editor__content:focus) {
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring), transparent 70%);
}

/* Toolbar */

.editor__toolbar {
  display: flex;
  align-items: center;
  gap: 0.15rem;
  padding: 0.4rem 0.5rem;
  border-bottom: 1px solid var(--border);
  background: color-mix(in oklab, var(--muted), transparent 45%);
}

.editor__tool {
  display: inline-grid;
  place-content: center;
  width: 1.9rem;
  height: 1.9rem;
  border-radius: var(--radius-sm);
  color: var(--muted-foreground);
  font-size: 0.95rem;
  transition: background-color 0.12s ease, color 0.12s ease;
}

.editor__tool:hover {
  background: var(--accent);
  color: var(--foreground);
}

.editor__tool:active {
  scale: 0.92;
}

.editor__toolbar-separator {
  width: 1px;
  height: 1.1rem;
  margin: 0 0.3rem;
  background: var(--border);
}

/* Content: prose typography */

.editor__content {
  position: relative;
  min-height: 11rem;
  max-height: 24rem;
  overflow-y: auto;
  padding: 1rem 1.15rem;
  font-size: var(--text-base);
  line-height: 1.7;
  outline: none;
  caret-color: var(--primary);
  overflow-wrap: break-word;
}

.editor__content:empty::before,
.editor__content:has(> br:only-child)::before {
  content: attr(data-placeholder);
  color: var(--muted-foreground);
  pointer-events: none;
  position: absolute;
}

.editor__content ul,
.editor__content ol {
  padding-left: 1.5rem;
  margin: 0.35rem 0;
}

.editor__content li {
  margin: 0.15rem 0;
}

.editor__content blockquote {
  border-left: 3px solid var(--border);
  padding-left: 0.9rem;
  color: var(--muted-foreground);
  margin: 0.5rem 0;
}

/* Footer: save state + counts */

.editor__footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.45rem 0.75rem;
  border-top: 1px solid var(--border);
  background: color-mix(in oklab, var(--muted), transparent 45%);
  font-size: var(--text-xs);
  color: var(--muted-foreground);
}

.editor-status {
  display: inline-flex;
  align-items: center;
  gap: 0.75rem;
  width: 100%;
  justify-content: space-between;
  font-variant-numeric: tabular-nums;
}

.editor-status__state {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-weight: 500;
}

.editor-status__dot {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: var(--muted-foreground);
}

.editor-status--saved .editor-status__dot {
  background: var(--success);
  animation: editor-dot-pop 0.3s ease;
}

.editor-status--saved .editor-status__state {
  color: var(--success);
}

@keyframes editor-dot-pop {
  from { scale: 0; }
  60% { scale: 1.5; }
  to { scale: 1; }
}

/* css/components/thread.css */
/* Thread: a conversation rail — messages and lifecycle events down one
   column, oldest first. Two densities, because a timeline where every
   entry looks the same is a wall: a message is a bubble with a byline, a
   lifecycle event is a single muted line. */

.thread {
  --thread-rail-x: 1.35rem; /* gutter the dots and the line live in */
  --thread-gap: 0.85rem;

  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--thread-gap);
  padding-left: var(--thread-rail-x);
  margin: 0;
}

/* Pinned: the rail becomes its own scroll container, which is what makes
   "follow the newest item" mean anything (see ds.js). The height is a
   custom property so a consumer can size it to its own layout without
   overriding the rule. */
.thread--pinned {
  max-height: var(--thread-max-h, 22rem);
  overflow-y: auto;
  /* Room for the dots, which sit in the padding and would otherwise be
     clipped against the scroll container's edge. */
  padding-right: 0.25rem;
  overscroll-behavior: contain;
}

.thread__item {
  position: relative;
}

/* The dot on the rail. Filled and card-colored so it reads as a node
   rather than as a bullet. */
.thread__item::before {
  content: "";
  position: absolute;
  left: calc(var(--thread-rail-x) * -1);
  top: 0.45rem;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 999px;
  background: color-mix(in oklab, var(--muted-foreground), transparent 45%);
}

/* The line between this dot and the next one. Drawn per item rather than
   as a border on the container so it stops at the last entry — a rail
   that runs past the final item reads as "more below", which is exactly
   the wrong thing to say at the end of a timeline. */
.thread__item::after {
  content: "";
  position: absolute;
  left: calc(var(--thread-rail-x) * -1 + 0.21rem);
  top: 1.1rem;
  bottom: calc(var(--thread-gap) * -1);
  width: 1px;
  background: var(--border);
}

.thread__item:last-child::after {
  display: none;
}

.thread__meta {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.35rem;
  font-size: var(--text-xs);
  color: var(--muted-foreground);
  min-width: 0;
}

.thread__body {
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--card);
  color: var(--card-foreground);
  padding: 0.6rem 0.75rem;
  font-size: var(--text-sm);
  /* An agent's progress note is machine-generated text with long paths and
     ids in it; without this one breaks the rail's width on a phone. */
  overflow-wrap: anywhere;
}

/* The compact density: a lifecycle event ("submitted", "reclaimed") is
   one muted line with no bubble, so the messages around it are what the
   eye lands on. Meta and body share the line. */
.thread__item--muted {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0 0.5rem;
  font-size: var(--text-xs);
  color: var(--muted-foreground);
}

.thread__item--muted .thread__meta {
  margin-bottom: 0;
}

.thread__item--muted .thread__body {
  border: none;
  background: none;
  padding: 0;
  font-size: inherit;
  color: inherit;
}

/* A hollow, smaller dot: still on the rail, but plainly a lesser event. */
.thread__item--muted::before {
  top: 0.35rem;
  width: 0.4rem;
  height: 0.4rem;
  background: var(--background);
  border: 1px solid var(--border);
}

.thread__item--muted::after {
  left: calc(var(--thread-rail-x) * -1 + 0.2rem);
  top: 0.85rem;
}

/* css/components/prose.css */
/* Prose: typography for server-rendered rich HTML.

   Every selector here is scoped under .prose, on purpose. The consumer's
   markdown renderer emits plain semantic HTML with no class names in it,
   so the styling has to key off elements — and element selectors that
   are not scoped would reach into every card, table and form on the
   page. The wrapper is what makes "style by element" safe. */

.prose {
  color: var(--foreground);
  font-size: var(--text-sm);
  line-height: 1.65;
}

/* The wrapper owns no outer spacing: the first and last blocks inside it
   would otherwise push it away from whatever it was dropped into, which
   is the classic reason a rendered description sits crooked in a card. */
.prose > :first-child {
  margin-top: 0;
}

.prose > :last-child {
  margin-bottom: 0;
}

/* ---- Blocks ---- */

.prose p {
  margin: 0.75em 0;
}

.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
  margin: 1.6em 0 0.6em;
  line-height: 1.3;
  font-weight: 600;
  letter-spacing: -0.01em;
}

/* Rendered markdown lands inside a page that already has an h1, so these
   step down from the page's scale rather than repeating it: an h1 in a
   description is the description's title, not the page's. */
.prose h1 { font-size: var(--text-xl); }
.prose h2 { font-size: var(--text-lg); }
.prose h3 { font-size: var(--text-base); }
.prose h4,
.prose h5,
.prose h6 {
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

.prose ul,
.prose ol {
  margin: 0.75em 0;
  padding-left: 1.4em;
}

.prose li {
  margin: 0.25em 0;
}

.prose li > ul,
.prose li > ol {
  margin: 0.25em 0;
}

/* Task lists. A markdown renderer emits `<li><input type="checkbox"
   disabled> …`; the marker beside the box is noise, so it goes. The
   checkbox keeps its native look — it is disabled, and a checkbox that
   looks interactive but is not is worse than a plain one. */
.prose li:has(> input[type="checkbox"]) {
  list-style: none;
  margin-left: -1.4em;
  padding-left: 1.4em;
}

.prose li > input[type="checkbox"] {
  margin-right: 0.4em;
  vertical-align: baseline;
  accent-color: var(--primary);
}

.prose blockquote {
  margin: 1em 0;
  padding: 0.1em 0 0.1em 0.9em;
  border-left: 3px solid var(--border);
  color: var(--muted-foreground);
}

.prose hr {
  margin: 1.5em 0;
  border: none;
  border-top: 1px solid var(--border);
}

.prose img {
  max-width: 100%;
  height: auto;
  border-radius: var(--radius-md);
  margin: 1em 0;
}

/* ---- Inline ---- */

.prose a {
  color: var(--foreground);
  text-decoration: underline;
  text-decoration-color: color-mix(in oklab, var(--foreground), transparent 60%);
  text-underline-offset: 0.2em;
}

.prose a:hover {
  text-decoration-color: currentColor;
}

.prose strong {
  font-weight: 600;
}

.prose code {
  font-family: var(--font-mono);
  font-size: 0.875em;
  background: var(--muted);
  color: var(--foreground);
  padding: 0.12em 0.35em;
  border-radius: var(--radius-sm);
  /* An inline path or identifier is the most common thing in a code span
     and the most likely to be longer than the column. */
  overflow-wrap: anywhere;
}

.prose kbd {
  font-family: var(--font-mono);
  font-size: 0.8em;
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  border-radius: var(--radius-sm);
  padding: 0.1em 0.4em;
  background: var(--background);
}

/* Fenced code. The block scrolls rather than wraps: wrapped code changes
   what the code looks like, and a log line or a diff read wrapped is a
   different line. */
.prose pre {
  margin: 1em 0;
  padding: 0.75rem 0.9rem;
  background: var(--muted);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  overflow-x: auto;
  font-size: var(--text-xs);
  line-height: 1.55;
}

.prose pre code {
  background: none;
  padding: 0;
  border-radius: 0;
  font-size: inherit;
  overflow-wrap: normal;
  white-space: pre;
}

/* ---- Tables ---- */

/* The wrapper a renderer emits around a table is not guaranteed, so the
   table itself carries the layout and the scroll lives on it via
   display: block — a table wider than the column must not widen the
   page, and on a detail view it usually is. */
.prose table {
  display: block;
  overflow-x: auto;
  width: 100%;
  margin: 1em 0;
  border-collapse: collapse;
  font-size: var(--text-xs);
}

.prose th,
.prose td {
  border: 1px solid var(--border);
  padding: 0.4rem 0.6rem;
  text-align: left;
  vertical-align: top;
}

.prose th {
  background: var(--muted);
  font-weight: 600;
}

/* ---- Callouts ---- */

/* The convention a renderer emits: <div class="callout callout--warning">.
   No modifier is the neutral note, so an unknown tone degrades to a plain
   callout instead of to an unstyled div. */
.prose .callout {
  --callout-tone: var(--muted-foreground);

  margin: 1em 0;
  padding: 0.7rem 0.9rem;
  border: 1px solid color-mix(in oklab, var(--callout-tone), transparent 70%);
  border-left: 3px solid var(--callout-tone);
  border-radius: var(--radius-md);
  background: color-mix(in oklab, var(--callout-tone), transparent 92%);
}

/* Tones are tinted, never filled: a callout sits inside running text, and
   a full-strength fill would read as a different page. The title takes
   the hue mixed toward the foreground, because the raw tokens are tuned
   as fills behind white text and are too light to read as text on their
   own tint. */
.prose .callout--tip {
  --callout-tone: var(--success);
}

.prose .callout--warning {
  /* No warning token in the palette; this is the amber the theme would
     have if it had one, and it is legible on both backgrounds. */
  --callout-tone: oklch(0.72 0.16 75);
}

.prose .callout--danger {
  --callout-tone: var(--destructive);
}

.prose .callout__title {
  margin: 0 0 0.25em;
  font-weight: 600;
  color: color-mix(in oklab, var(--callout-tone), var(--foreground) 30%);
}

.prose .callout > :first-child {
  margin-top: 0;
}

.prose .callout > :last-child {
  margin-bottom: 0;
}

/* css/components/annotatedlines.css */
/* AnnotatedLines: text as numbered, selectable rows with a comment
   gutter. The consumer renders the lines; everything here is the
   affordance around them.

   The block sets no maximum height and is not a scroll container. That
   is deliberate twice over: a description should grow with the document
   it is in, and the comment pill is positioned inside the row it belongs
   to — a scroll container between the two would leave it stranded. A
   consumer that really wants a bounded viewer puts one around this. */

.annotated-lines {
  /* The two gutters, exposed so a consumer with five-digit line numbers
     or a wider marker can widen them without rewriting the grid. */
  --annotated-num-w: 2.75rem;
  --annotated-gutter-w: 1.75rem;

  position: relative;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--card);
  color: var(--card-foreground);
  font-size: var(--text-sm);
}

.annotated-lines__list {
  list-style: none;
  margin: 0;
  padding: 0.35rem 0;
}

/* One grid row per line: number, marker gutter, content. position:
   relative is what the comment pill anchors to once ds.js moves it in. */
.annotated-lines__line {
  position: relative;
  display: grid;
  grid-template-columns: var(--annotated-num-w) var(--annotated-gutter-w) minmax(0, 1fr);
  align-items: start;
}

/* The number is a button, so it needs the reset. Right-aligned and
   tabular so the column stays a column at any width.

   The digits are DRAWN here, out of the attribute, rather than sitting
   in the button as text — see the comment in annotatedlines.templ.
   Selecting a few lines and copying them has to yield the text and not
   the gutter, and user-select: none does not deliver that on its own.
   The CONTENT is deliberately left selectable; only the chrome is not. */
.annotated-lines__num::before {
  content: attr(data-annotated-num);
}

.annotated-lines__num {
  appearance: none;
  border: 0;
  background: none;
  cursor: pointer;
  user-select: none;
  padding: 0.1rem 0.6rem 0.1rem 0;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  line-height: 1.6;
  text-align: right;
  color: var(--muted-foreground);
}

.annotated-lines__num:hover {
  color: var(--foreground);
  background: var(--muted);
}

/* The arrow keys move a single tab stop through the numbers, so the
   focus ring is the only thing telling a keyboard user where they are.
   It has to be unmissable, and it must not be the row tint — that means
   something else. */
.annotated-lines__num:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: -2px;
  color: var(--foreground);
}

.annotated-lines__gutter {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 1.6em;
  user-select: none;
}

.annotated-lines__content {
  padding: 0.1rem 0.6rem 0.1rem 0.2rem;
  line-height: 1.6;
  /* Machine-generated text — paths, ids, URLs — otherwise sets the
     width of the whole block on a phone. */
  overflow-wrap: anywhere;
}

/* A code line keeps its own spacing, so it scrolls rather than wraps:
   wrapped code is a different thing to read, and a shifted indent is a
   different program. */
.annotated-lines__line--code .annotated-lines__content {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  white-space: pre;
  overflow-x: auto;
}

/* Selected rows. The class is added by ds.js and never rendered by the
   server: a selection is where the reader is looking right now, and a
   page reloaded mid-selection should come back with nothing selected. */
.annotated-lines__line--selected {
  background: color-mix(in oklab, var(--primary), transparent 92%);
}

.annotated-lines__line--selected .annotated-lines__num {
  color: var(--foreground);
  font-weight: 600;
}

/* A left edge on the selected block, so a range still reads as one
   thing on a monitor where the tint is barely visible. */
.annotated-lines__line--selected::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 2px;
  background: var(--primary);
}

/* The gutter marker: a line that already carries comments. Sized to
   stay a target on a touch screen without widening the gutter. */
.annotated-lines__marker {
  appearance: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.25rem;
  height: 1.25rem;
  padding: 0 0.25rem;
  border: 1px solid color-mix(in oklab, var(--primary), transparent 70%);
  border-radius: 999px;
  background: color-mix(in oklab, var(--primary), transparent 88%);
  color: var(--foreground);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

.annotated-lines__marker:hover {
  background: color-mix(in oklab, var(--primary), transparent 78%);
}

/* The annotation popover. Top layer, so opening one neither reflows the
   text nor scrolls it — the reader keeps their place, which is the
   whole point of showing comments in a gutter. */
.annotated-lines__popover {
  position: fixed;
  margin: 0;
  inset: auto;
  top: anchor(bottom);
  left: anchor(left);
  width: min(22rem, calc(100vw - 2rem));
  max-height: min(60dvh, 22rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--popover);
  color: var(--popover-foreground);
  box-shadow: var(--shadow-md);
  font-size: var(--text-sm);
}

/* Browsers without anchor positioning ignore anchor() and get a
   centered panel — detached from the line, still readable and still
   dismissible, which is the part that matters. */
@supports not (top: anchor(bottom)) {
  .annotated-lines__popover {
    inset: 0;
    margin: auto;
    height: fit-content;
  }
}

.annotated-lines__popover::backdrop {
  background: transparent;
}

/* The comment control. ds.js unhides it and moves it into the last
   selected row, so it is positioned against that row rather than
   against the block. */
.annotated-lines__action {
  position: absolute;
  top: 50%;
  right: 0.4rem;
  translate: 0 -50%;
  z-index: 1;
}

.annotated-lines__comment {
  appearance: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.2rem 0.6rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  /* Opaque: it sits over the line's own text. */
  background: var(--popover);
  color: var(--popover-foreground);
  box-shadow: var(--shadow-sm);
  font-size: var(--text-xs);
  font-weight: 600;
  white-space: nowrap;
}

.annotated-lines__comment:hover {
  background: var(--accent);
  color: var(--accent-foreground);
}

/* The range on the pill is the answer to "what am I about to comment
   on" — the selection tint says where, this says exactly which. */
.annotated-lines__range {
  font-variant-numeric: tabular-nums;
  font-weight: 500;
  opacity: 0.75;
}

/* Touch: the numbers are the only way to select a range, and a 0.75rem
   glyph is not a target. Widening the column is cheaper than a second
   interaction model. */
@media (pointer: coarse) {
  .annotated-lines {
    --annotated-num-w: 3.25rem;
  }

  .annotated-lines__num {
    padding-top: 0.35rem;
    padding-bottom: 0.35rem;
  }
}

/* The code block's SURFACE ships with every consumer; the syntax
   palette does not — that is css/highlight.css, linked only by pages
   that show code. */
/* css/components/codeblock.css */
/* CodeBlock: a bordered, scrolling block of source, with an optional
   filename caption.

   Everything here is surface — the box, the border, the padding, the
   scrolling. Not one syntax colour, because the palette is opt-in and
   lives in highlight.css. A consumer who never loads the highlighter
   still gets a code block that looks like one; a consumer who does gets
   the same box with colour in it. */

.code-block {
  margin: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--muted);
  overflow: hidden;
}

/* The caption is chrome, not code: it sits on its own strip so the
   monospace block below it starts at the left edge like code should. */
.code-block__name {
  padding: 0.35rem 0.7rem;
  border-bottom: 1px solid var(--border);
  background: var(--card);
  color: var(--muted-foreground);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}

/* Scrolls rather than wraps by default. A wrapped log line or diff is a
   different thing to read, and a re-flowed indent is a different
   program — see CodeBlockProps.Wrap for the opt-out. */
.code-block__pre {
  margin: 0;
  padding: 0.7rem 0.8rem;
  overflow-x: auto;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  line-height: 1.6;
  color: var(--foreground);
  tab-size: 2;
}

.code-block__code {
  font: inherit;
  color: inherit;
  background: none;
  padding: 0;
}

.code-block--wrap .code-block__pre {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* css/components/pagebar.css */
/* PageBar: the sticky bar at the top of a detail view — a way back, what
   you are looking at, what you can do to it. */

.page-bar {
  position: sticky;
  /* Park below AppShell's header instead of underneath it. The shell
     publishes its height as a custom property, so this needs no
     measurement and cannot drift from it; outside a shell the fallback
     puts the bar at the top of its own scroll container. */
  top: var(--appshell-header-h, 0rem);
  /* Under the header (40) and under the mobile nav sheet (30): if the
     three ever meet, chrome that belongs to the whole app should win
     over chrome that belongs to one view. */
  z-index: 20;

  display: flex;
  align-items: center;
  gap: 0.75rem;
  width: 100%;
  min-height: 3rem;
  padding: 0.5rem 0.25rem;
  /* Same elevation language as the shell header: translucent over blur,
     so content scrolling underneath is visible as motion but never
     legible through the bar. */
  background: color-mix(in oklab, var(--background), transparent 12%);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--border);
}

.page-bar__lead,
.page-bar__trail {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  /* Neither end shrinks. On a narrow screen the title gives way instead:
     a half-read title still says what you are looking at, an action
     pushed off the edge is unreachable. */
  flex-shrink: 0;
}

.page-bar__trail {
  margin-left: auto;
}

.page-bar__main {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  /* min-width: 0 is what actually lets a flex child shrink below its
     content — without it the title refuses to truncate and pushes the
     actions out of the bar instead. */
  min-width: 0;
  overflow: hidden;
}

/* Truncation for the usual middle: a heading, optionally followed by
   status chips. Element selectors so a consumer's own title markup gets
   this for free — the bar has to survive whatever the page calls it. */
.page-bar__main > h1,
.page-bar__main > h2,
.page-bar__main > h3 {
  font-size: var(--text-lg);
  font-weight: 600;
  letter-spacing: -0.01em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  /* Shrinkable, but never all the way: status chips beside the title do
     not shrink, and without a floor they squeeze it out of existence —
     "truncated to nothing" is not truncation, it is a missing title. */
  flex: 1 1 auto;
  min-width: 4rem;
}

@media (max-width: 640px) {
  /* Two rows on a phone: the way back beside the title, the actions
     under them. Grid rather than flex-wrap, because a wrapping flex line
     wraps an item BEFORE shrinking it — a long title would take a whole
     row to itself and push the back button onto its own, turning the bar
     into three rows of chrome above the content. Nothing reorders: back,
     title, actions is still the reading order. */
  .page-bar {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas:
      "lead main"
      "trail trail";
    gap: 0.35rem 0.5rem;
    min-height: 2.75rem;
  }

  .page-bar__lead {
    grid-area: lead;
  }

  .page-bar__main {
    grid-area: main;
    /* Status beside a title is the first thing to give way here: it drops
       under the title rather than eating the words. */
    flex-wrap: wrap;
  }

  .page-bar__trail {
    grid-area: trail;
    justify-content: flex-end;
    margin-left: 0;
  }

  .page-bar__main > h1,
  .page-bar__main > h2,
  .page-bar__main > h3 {
    font-size: var(--text-base);
    flex-basis: 100%;
  }
}

