/* ==========================================================================
   FERNGLAS — COMPONENT STYLES. Consume semantic tokens directly. Every
   class uses a plain, self-explanatory name (.btn, .card, .icon-button) —
   no namespace prefix. Default size is "md" (no attribute needed); add
   [data-size="sm"] or [data-size="lg"] for the other two steps.
   ========================================================================== */

/* -- Typography: utilities for when the visual size/weight needs to be
   set independent of the element (a <span> that reads as an eyebrow, a
   <div> that needs heading-2 sizing without being a heading). Bare
   h1-h6 already get sensible defaults from base.css — reach for these
   only when the two need to diverge. -- */
.heading-1,
.heading-2,
.heading-3 {
  font-weight: var(--weight-bold);
  line-height: var(--leading-tight);
  text-wrap: balance;
}
.heading-1 {
  font-size: var(--text-3xl);
  letter-spacing: var(--tracking-tight);
}
.heading-2 {
  font-size: var(--text-2xl);
}
.heading-3 {
  font-size: var(--text-xl);
}
.heading-4 {
  font-size: var(--text-lg);
  font-weight: var(--weight-semibold);
  line-height: var(--leading-tight);
}
.text-lead {
  font-size: var(--text-lg);
  line-height: var(--leading-relaxed);
  color: var(--text-muted);
}
.text-body {
  font-size: var(--text-md);
  line-height: var(--leading-normal);
}
.text-small {
  font-size: var(--text-sm);
  color: var(--text-muted);
}
.eyebrow {
  display: inline-block;
  font-size: var(--text-xs);
  font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-muted);
}
.text-mono {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}
/* Caps long-form text at a readable line length regardless of the
   container's actual width — the ~45-75 characters/line range readability
   research settles on. */
.prose {
  max-width: 65ch;
  line-height: var(--leading-relaxed);
}
.text-truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* -- Button -- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--control-height-md);
  padding-block: var(--space-2);
  padding-inline: var(--space-4);
  border-radius: var(--radius-md);
  border: 1px solid transparent;
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  transition: background-color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.btn[data-size='sm'] {
  min-height: var(--control-height-sm);
  padding-block: var(--space-1);
  padding-inline: var(--space-3);
  font-size: var(--text-xs);
}
.btn[data-size='lg'] {
  min-height: var(--control-height-lg);
  padding-block: var(--space-3);
  padding-inline: var(--space-6);
  font-size: var(--text-md);
}
.btn:disabled {
  opacity: var(--opacity-disabled);
  cursor: not-allowed;
}
.btn-primary {
  background: var(--btn-primary-bg);
  color: var(--on-accent);
}
.btn-primary:not(:disabled):hover {
  background: var(--btn-primary-hover);
}
.btn-ghost {
  background: var(--surface);
  border-color: var(--border);
  color: var(--text);
}
.btn-ghost:not(:disabled):hover {
  background: var(--surface-sunken);
}
.btn-danger {
  background: var(--err);
  color: var(--on-accent);
}
.btn-danger:not(:disabled):hover {
  background: var(--err-hover);
}
.btn-success {
  background: var(--ok);
  color: var(--on-accent);
}
.btn-success:not(:disabled):hover {
  background: var(--ok-hover);
}
.btn-info {
  background: var(--info);
  color: var(--on-accent);
}
.btn-info:not(:disabled):hover {
  background: var(--info-hover);
}
.btn-navy {
  background: var(--navy);
  color: var(--on-accent);
}
.btn-navy:not(:disabled):hover {
  background: var(--navy-hover);
}
.btn-ruby {
  background: var(--ruby);
  color: var(--on-accent);
}
.btn-ruby:not(:disabled):hover {
  background: var(--ruby-hover);
}
.btn-gold {
  background: var(--gold);
  color: var(--on-accent);
}
.btn-gold:not(:disabled):hover {
  background: var(--gold-hover);
}

/* -- Icon button (bookmark, close, external-link — anything glyph-only) -- */
.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--control-height-md);
  height: var(--control-height-md);
  flex-shrink: 0;
  padding: 0;
  border: none;
  border-radius: var(--radius-md);
  background: transparent;
  color: var(--text-muted);
  font-size: var(--text-lg);
  line-height: var(--leading-none);
  transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.icon-btn[data-size='sm'] {
  width: var(--control-height-sm);
  height: var(--control-height-sm);
  font-size: var(--text-md);
}
.icon-btn[data-size='lg'] {
  width: var(--control-height-lg);
  height: var(--control-height-lg);
  font-size: var(--text-xl);
}
.icon-btn:not(:disabled):hover {
  background: var(--surface-sunken);
  color: var(--text);
}
.icon-btn.active {
  color: var(--accent);
}
.icon-btn:disabled {
  opacity: var(--opacity-disabled);
  cursor: not-allowed;
}

/* -- Icon button, primary (filled) — same square glyph-only footprint as
   .icon-btn, but for an icon-only action that's the primary action in
   its context (e.g. a toolbar's main button with no room for a label). -- */
.icon-btn-primary {
  background: var(--btn-primary-bg);
  color: var(--on-accent);
}
.icon-btn-primary:not(:disabled):hover {
  background: var(--btn-primary-hover);
  color: var(--on-accent);
}
.icon-btn-danger {
  background: var(--err);
  color: var(--on-accent);
}
.icon-btn-danger:not(:disabled):hover {
  background: var(--err-hover);
  color: var(--on-accent);
}

/* -- Icon button, stretch-to-row fit — drop the fixed square sizes so the
   button instead matches the cross-size of a sibling row item (e.g. an
   input/select next to it), staying square via aspect-ratio. Needs a
   GRID row with align-items: stretch (grid-auto-flow: column,
   grid-auto-columns: max-content) — flexbox does not reliably transfer a
   stretched cross size back into an auto aspect-ratio main size, so a
   plain flex row leaves this non-square. See the Icon button gallery
   page's "Stretch-to-row fit" demo for the working pattern. -- */
.icon-btn[data-fit='stretch'] {
  align-self: stretch;
  width: auto;
  height: auto;
  aspect-ratio: 1 / 1;
}

/* -- Chip -- */
.chip {
  display: inline-flex;
  align-items: center;
  padding-block: var(--space-1);
  padding-inline: var(--space-3);
  border-radius: var(--radius-full);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  border: 1px solid transparent;
  white-space: nowrap;
}
.chip[data-size='sm'] {
  padding-block: var(--space-0);
  padding-inline: var(--space-2);
}
.chip[data-size='lg'] {
  padding-block: var(--space-2);
  padding-inline: var(--space-4);
  font-size: var(--text-sm);
}
.chip-neutral {
  background: var(--surface-sunken);
  color: var(--text-muted);
  border-color: var(--border);
}
.chip-accent {
  background: var(--accent-soft);
  /* --text, not --accent: a 14% accent tint doesn't reliably leave
     enough contrast margin against the accent color itself across an
     arbitrary branded accent — see semantic.css's --accent-soft note. */
  color: var(--text);
}
.chip-ok {
  background: var(--ok-bg);
  color: var(--ok);
}
.chip-err {
  background: var(--err-bg);
  color: var(--err);
}
/* -- Chip, removable: adds an inline dismiss control — Carbon/Atlassian's
   Tag always has one; a display-only Chip and a removable one share the
   same base class, [data-removable] just makes room for the button. -- */
.chip[data-removable] {
  padding-inline-end: var(--space-1);
  gap: var(--space-1);
}
.chip-remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  border: none;
  border-radius: var(--radius-full);
  background: none;
  color: inherit;
  opacity: 0.7;
}
.chip-remove:hover {
  opacity: 1;
  /* currentColor, not a hardcoded rgb(0 0 0 / …) — this chip's text color
     already varies per variant (neutral/accent/ok/err) and per theme, so
     mixing against it keeps the hover legible in all of them instead of
     just light mode. */
  background: color-mix(in srgb, currentColor 15%, transparent);
}

/* -- StatTile -- */
.stat-tile {
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  padding: var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.stat-tile-value {
  font-size: var(--text-2xl);
  font-weight: var(--weight-bold);
  color: var(--text);
}
.stat-tile-label {
  font-size: var(--text-sm);
  color: var(--text-muted);
}
.stat-tile-hint {
  font-size: var(--text-xs);
  color: var(--text-muted);
}

/* -- Dialog (wraps the native <dialog> — free focus trap, Esc, backdrop) -- */
.dialog {
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 0;
  background: var(--surface-raised);
  color: var(--text);
  box-shadow: var(--shadow-elevation-3);
  max-width: min(480px, 90vw);
  width: 100%;
}
.dialog::backdrop,
.drawer::backdrop {
  background: rgb(0 0 0 / 0.5);
}
/* -- Dialog header: a raised band behind the title so it reads as
   structurally distinct from the body prose below it — same visual job
   .surface-sunken does elsewhere for "this region is a step apart". -- */
.dialog-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  padding-block: var(--space-4);
  padding-inline: var(--space-6);
  background: var(--surface-sunken);
  border-block-end: 1px solid var(--border);
  border-start-start-radius: var(--radius-lg);
  border-start-end-radius: var(--radius-lg);
}
.dialog-title {
  font-size: var(--text-lg);
  font-weight: var(--weight-bold);
  margin: 0;
}
.dialog-body {
  padding: var(--space-6);
}
.dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-3);
  margin-block-start: var(--space-6);
}

/* -- Toast -- */
.toast-host {
  position: fixed;
  inset-block-end: var(--space-4);
  inset-inline-end: var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  z-index: var(--z-toast);
  max-width: min(360px, calc(100vw - var(--space-8)));
}
.toast {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-3);
  padding-block: var(--space-3);
  padding-inline: var(--space-4);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-elevation-2);
  font-size: var(--text-sm);
  animation: toast-in var(--dur-base) var(--ease);
}
.toast[data-closing] {
  animation: toast-out var(--dur-fast) var(--ease) forwards;
}
.toast .icon-btn {
  /* Pinned to the sm control-height step, not the new md default (44px) —
     a toast's own type scale is --text-sm/space-3, and a dismiss button
     at the global default would make a single-line toast grow just to
     fit it. */
  width: var(--control-height-sm);
  height: var(--control-height-sm);
  flex-shrink: 0;
  color: inherit;
}
.toast-info {
  background: var(--surface-raised);
  border: 1px solid var(--border);
  color: var(--text);
}
.toast-ok {
  background: var(--ok-bg);
  color: var(--ok);
  /* --ok/--err, not --ok-border/--err-border — the -border tokens are
     tuned for a border against a neutral surface (input/uploader error
     state); here the border sits on the matching tinted -bg, and in dark
     mode -border resolves to a dark, low-contrast shade that nearly
     disappears against it (~2.2:1). --ok/--err are already the
     dark-mode-lightened value, same as --info/--warn use for this. */
  border: 1px solid var(--ok);
}
.toast-err {
  background: var(--err-bg);
  color: var(--err);
  border: 1px solid var(--err);
}
@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
@keyframes toast-out {
  from {
    opacity: 1;
    transform: translateY(0);
  }
  to {
    opacity: 0;
    transform: translateY(8px);
  }
}

/* -- Toggle: track + thumb built from a visually-hidden checkbox, not
   accent-color — accent-color only renders role="switch" as a pill in
   Chromium, so Firefox/Safari fell back to a plain checkbox square. This
   version looks and behaves like a switch in every browser. -- */
.toggle-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  padding-block: var(--space-3);
}
.toggle-text {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.toggle-label {
  font-weight: var(--weight-semibold);
}
.toggle-hint {
  font-size: var(--text-sm);
  color: var(--text-muted);
}
.toggle-switch {
  position: relative;
  display: inline-flex;
  flex-shrink: 0;
  width: 40px;
  height: 22px;
}
.toggle-input {
  position: absolute;
  inset: 0;
  margin: 0;
  opacity: 0;
  z-index: 1;
  cursor: pointer;
}
.toggle-track {
  position: absolute;
  inset: 0;
  background: var(--border-strong);
  border-radius: var(--radius-full);
  transition: background-color var(--dur-fast) var(--ease);
}
.toggle-thumb {
  position: absolute;
  inset-block-start: 2px;
  inset-inline-start: 2px;
  width: 18px;
  height: 18px;
  border-radius: var(--radius-full);
  background: var(--surface);
  transition: transform var(--dur-fast) var(--ease);
}
.toggle-input:checked ~ .toggle-track {
  background: var(--accent);
}
.toggle-input:checked ~ .toggle-track .toggle-thumb {
  transform: translateX(18px);
}
.toggle-input:focus-visible ~ .toggle-track {
  outline: var(--focus-ring);
  outline-offset: 2px;
}
.toggle-input:disabled ~ .toggle-track {
  opacity: var(--opacity-disabled);
  cursor: not-allowed;
}

/* -- Option row: a generic selectable list row (radio/checkbox-like) -- */
.option-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.option-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding-block: var(--space-3);
  padding-inline: var(--space-4);
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  text-align: start;
  /* One step down from the inherited md default — an option list is
     usually many rows of short answer text, so the density matters more
     here than for prose. */
  font-size: var(--text-sm);
  transition: background-color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.option-row:not(:disabled):hover {
  background: var(--surface-sunken);
}
.option-row-checked {
  border-color: var(--accent);
  background: var(--accent-soft);
}
.option-row-marker {
  flex-shrink: 0;
  width: 28px;
  height: 28px;
  border-radius: var(--radius-full);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: var(--weight-bold);
  font-size: var(--text-xs);
  background: var(--surface-sunken);
  color: var(--text-muted);
}
.option-row-checked .option-row-marker {
  background: var(--accent);
  color: var(--on-accent);
}
.option-row-correct {
  border-color: var(--ok);
  background: var(--ok-bg);
}
.option-row-correct .option-row-marker {
  background: var(--ok);
  color: var(--on-accent);
}
.option-row-wrong {
  border-color: var(--err);
  background: var(--err-bg);
}
.option-row-wrong .option-row-marker {
  background: var(--err);
  color: var(--on-accent);
}
.option-row-missed {
  opacity: 0.7;
}
.option-row:disabled {
  cursor: default;
}

/* -- Nav: a simple tab bar. flex-wrap was dropped — with it set, a row
   that's too narrow wraps onto a second line before overflow-x: auto ever
   gets a chance to kick in, so the promised horizontal-scroll behavior
   (and native's ScrollView, which never wraps either) could never actually
   happen on web. justify-content: flex-start (the default, stated
   explicitly) replaces a prior center, which also diverged from native —
   RN's ScrollView lays items out start-aligned, with no equivalent of
   centering a horizontally-scrolling row. -- */
.nav {
  display: flex;
  justify-content: flex-start;
  gap: var(--space-2);
  padding: var(--space-2);
  background: var(--surface);
  border-block-end: 1px solid var(--border);
  overflow-x: auto;
}
.nav-item {
  white-space: nowrap;
}
.nav-item[aria-current='page'] {
  background: var(--accent-soft);
  color: var(--accent);
}

/* -- Segmented control: a compact, mutually-exclusive multi-way switch
   (provider tabs, view-mode toggles) - visually distinct from .nav above
   (a plain tab bar) by its sunken track + raised active pill, closer to
   a native segmented control. Composes with .btn for sizing/padding/font
   like .nav-item does above, but NOT .btn-ghost - ghost's own resting
   background/border would show through as a bordered box floating inside
   the sunken track on every item, not just the active one; .segmented-item
   owns the flat-until-active treatment itself. Active state via
   aria-current="page", same convention .nav-item uses for the same
   "switches a view in place" reason. -- */
.segmented {
  display: flex;
  gap: var(--space-1);
  padding: var(--space-1);
  background: var(--surface-sunken);
  border: var(--border-width-thin) solid var(--border);
  border-radius: var(--radius-md);
}
.segmented-item {
  flex: 1;
  justify-content: center;
  background: transparent;
  border-color: transparent;
  color: var(--text-muted);
}
.segmented-item:not(:disabled):hover {
  color: var(--text);
}
.segmented-item[aria-current='page'] {
  background: var(--surface-raised);
  border-color: transparent;
  color: var(--text);
  box-shadow: var(--shadow-elevation-1);
}

/* -- Sidebar nav: a persistent vertical nav rail for a multi-section app —
   distinct from .nav above, which is a horizontal tab bar for switching
   views in place. [data-collapsed] drops to icon-only width; pair each
   item with [data-tooltip][data-tooltip-position="end"] for the hidden
   label instead of a second overlay mechanism. -- */
.sidebar-nav {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  width: 240px;
  padding: var(--space-3);
  background: var(--surface);
  border-inline-end: var(--border-width-thin) solid var(--border);
}
.sidebar-nav[data-collapsed] {
  width: 64px;
  align-items: center;
}
.sidebar-nav-item {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
  padding-block: var(--space-2);
  padding-inline: var(--space-3);
  border-radius: var(--radius-md);
  color: var(--text-muted);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  white-space: nowrap;
  transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.sidebar-nav[data-collapsed] .sidebar-nav-item {
  width: 40px;
  height: 40px;
  padding: 0;
  justify-content: center;
}
.sidebar-nav[data-collapsed] .sidebar-nav-label {
  display: none;
}
.sidebar-nav-item:hover {
  background: var(--surface-sunken);
  color: var(--text);
}
.sidebar-nav-item[aria-current='page'] {
  background: var(--accent-soft);
  color: var(--accent);
}

/* -- Menu: a popover action list anchored below its trigger. Wraps native
   <details>/<summary> — same disclosure-widget approach as Accordion above
   — rather than the [popover] attribute: a real [popover] element is
   promoted to the top layer, which detaches it from its trigger's
   containing block and breaks position: absolute anchoring entirely.
   <details> stays in normal flow, so .menu-anchor's position: relative
   anchors .menu correctly with zero JS for open/close. Click-outside-
   to-close is the one thing <details> doesn't give you for free — see
   gallery/shared/demos.js's wireMenus() for the ~10-line reference wiring. -- */
.menu-anchor {
  position: relative;
  display: inline-block;
}
.menu-anchor > summary {
  list-style: none;
  cursor: pointer;
}
.menu-anchor > summary::-webkit-details-marker {
  display: none;
}
.menu {
  position: absolute;
  inset-block-start: calc(100% + var(--space-1));
  inset-inline-start: 0;
  margin: 0;
  min-width: 180px;
  padding: var(--space-1);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  box-shadow: var(--shadow-elevation-3);
  color: var(--text);
  display: flex;
  flex-direction: column;
  gap: var(--space-0);
  z-index: var(--z-overlay);
}
.menu-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  padding-block: var(--space-2);
  padding-inline: var(--space-3);
  border: none;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--text);
  font-size: var(--text-sm);
  text-align: start;
  cursor: pointer;
}
.menu-item:not(:disabled):hover,
.menu-item:not(:disabled):focus-visible {
  background: var(--surface-sunken);
}
.menu-item:disabled {
  opacity: var(--opacity-disabled);
  cursor: not-allowed;
}
.menu-item-danger {
  color: var(--err);
}
.menu-divider {
  border: none;
  border-block-start: 1px solid var(--border);
  margin-block: var(--space-1);
}

/* -- Badge: a small count/dot indicator anchored to a relatively-positioned
   wrapper (.badge-anchor) around any trigger — icon button, avatar, nav
   item. A modifier, not a standalone control, same relationship
   .avatar-status has to .avatar above. -- */
.badge-anchor {
  position: relative;
  display: inline-flex;
}
.badge {
  position: absolute;
  inset-block-start: -4px;
  inset-inline-end: -4px;
  min-width: 16px;
  height: 16px;
  padding-inline: var(--space-1);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-full);
  background: var(--err);
  color: var(--on-accent);
  /* Below --text-xs (the real floor for anything that's actually read as
     text) on purpose — this is a single notification-count digit inside
     a fixed 16px dot, closer to iconography than body copy. Not a
     candidate for a new type-scale step; don't reuse this value for
     real text. */
  /* stylelint-disable-next-line scale-unlimited/declaration-strict-value */
  font-size: 10px;
  font-weight: var(--weight-bold);
  line-height: var(--leading-none);
  border: var(--border-width-thick) solid var(--surface);
  /* The badge sits visually on top of its trigger (icon button, avatar) —
     without this, a click landing on the badge's own small hit area
     would miss the button/link underneath instead of activating it. */
  pointer-events: none;
}
.badge[data-variant='neutral'] {
  background: var(--accent);
}
.badge[data-variant='dot'] {
  min-width: 9px;
  width: 9px;
  height: 9px;
  padding: 0;
}

/* -- Table: minimal data table — border-collapse, sunken header, hover
   row, [data-align] on th/td for numeric columns. No sticky header/sort/
   virtualization — add those in the consuming project the day a table
   actually needs them; this is a personal system for two projects, not a
   data-grid library. -- */
.table {
  width: 100%;
  border-collapse: collapse;
  border: 1px solid var(--border);
  font-size: var(--text-sm);
}
.table th,
.table td {
  padding-block: var(--space-2);
  padding-inline: var(--space-3);
  text-align: start;
  border-block-end: 1px solid var(--border);
  border-inline-end: 1px solid var(--border);
}
.table th:last-child,
.table td:last-child {
  border-inline-end: none;
}
.table th {
  background: var(--surface-sunken);
  color: var(--text-muted);
  font-weight: var(--weight-semibold);
  white-space: nowrap;
}
.table tbody tr:hover {
  background: var(--surface-sunken);
}
.table [data-align='end'] {
  text-align: end;
}
.table [data-align='center'] {
  text-align: center;
}
/* -- Table sort: opt-in per column via a <button class="table-sort"> inside
   the <th> (see gallery/shared/demos.js's wireTableSort()) — most demo
   tables have no sortable columns at all, so this is a button a header
   can contain, not a behavior every .table th gets. Cycles asc → desc →
   none (data-sort-dir), reset restores the original row order. -- */
.table-sort {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.table-sort-icon {
  opacity: var(--opacity-disabled);
}
.table-sort[data-sort-dir='asc'] .table-sort-icon,
.table-sort[data-sort-dir='desc'] .table-sort-icon {
  opacity: 1;
}
.table-sort[data-sort-dir='desc'] .table-sort-icon {
  transform: rotate(180deg);
}

/* -- Label / Field: a Label pairs with any control below (input, select,
   textarea, a custom OptionRow list) — [data-required] appends a colored
   asterisk via ::after, one convention instead of every field hand-typing
   its own "*". Field just groups Label + control + .field-hint (below)
   with consistent spacing, since each control's own margin does the rest. -- */
.label {
  display: block;
  margin-block-end: var(--space-1);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  color: var(--text);
}
.label[data-required]::after {
  content: ' *';
  color: var(--err);
}
.field {
  display: flex;
  flex-direction: column;
}

/* -- Field group: groups a related set of Checkbox/Radio rows under one
   label — a native <fieldset>/<legend> pair (free keyboard/AT grouping
   semantics, no ARIA needed) reset to match .label instead of the
   browser's inset-border/padding default. Every design system surveyed
   here ships an equivalent (Polaris' Choice list, Carbon's fieldset
   pattern) — this was the one Forms gap that isn't a new control, just
   missing semantics around ones that already exist. -- */
.field-group {
  margin: 0;
  padding: 0;
  border: none;
}
.field-group > legend {
  padding: 0;
  margin-block-end: var(--space-1);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  color: var(--text);
}

/* -- Select / Input -- */
.select,
.input {
  min-height: var(--control-height-md);
  padding-block: var(--space-2);
  padding-inline: var(--space-3);
  border-radius: var(--radius-md);
  border: 1px solid var(--border-strong);
  background: var(--surface);
  color: var(--text);
}
/* The native chevron sits flush against the border with no breathing room
   on the right — appearance: none drops it so a custom one (background
   image, so the surface/border/text underneath keep rendering normally —
   a mask on the whole element would blank those out too) can sit
   var(--space-3) in from the edge instead, matching the input's own
   inline padding. Two color variants because a background-image SVG
   can't inherit currentColor the way an inline <use> can. */
.select {
  appearance: none;
  -webkit-appearance: none;
  padding-inline-end: var(--space-7);
  background-repeat: no-repeat;
  background-position: right var(--space-3) center;
  background-size: 16px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235a5a61' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='5 9 12 16 19 9'/%3E%3C/svg%3E");
}
:root[data-theme='dark'] .select {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239d9da6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='5 9 12 16 19 9'/%3E%3C/svg%3E");
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .select {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239d9da6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='5 9 12 16 19 9'/%3E%3C/svg%3E");
  }
}
.select[data-size='sm'],
.input[data-size='sm'] {
  min-height: var(--control-height-sm);
  padding-block: var(--space-1);
  padding-inline: var(--space-2);
  font-size: var(--text-sm);
}
.select[data-size='lg'],
.input[data-size='lg'] {
  min-height: var(--control-height-lg);
  padding-block: var(--space-3);
  padding-inline: var(--space-4);
  font-size: var(--text-md);
}
/* padding-inline above resets padding-inline-end too — restore room for
   the chevron at each size (sm gets a smaller icon to match its scale). */
.select[data-size='sm'] {
  padding-inline-end: var(--space-6);
  background-size: 14px;
  background-position: right var(--space-2) center;
}
.select[data-size='lg'] {
  padding-inline-end: var(--space-8);
}
/* background-position has no logical "inline-end" keyword, so RTL needs
   its own physical-side flip — same :dir(rtl) approach Drawer already
   uses for its slide-in direction. */
.select:dir(rtl) {
  background-position: left var(--space-3) center;
}
.select[data-size='sm']:dir(rtl) {
  background-position: left var(--space-2) center;
}
.select:focus,
.select:focus-visible,
.input:focus,
.input:focus-visible {
  outline: none;
  border-color: var(--accent);
}
.select:disabled,
.input:disabled {
  opacity: var(--opacity-disabled);
  cursor: not-allowed;
}
/* WebKit/Blink draw their own clear ("x") button on [type="search"],
   duplicating the themed .icon-btn clear button the Search field pattern
   already provides (and Firefox draws no native one at all) — suppressed
   so there's exactly one clear control, consistent across browsers. */
.input[type='search']::-webkit-search-cancel-button {
  -webkit-appearance: none;
  appearance: none;
}
.select[data-state='error'],
.input[data-state='error'] {
  border-color: var(--err-border);
}
.select[data-state='error']:focus,
.select[data-state='error']:focus-visible,
.input[data-state='error']:focus,
.input[data-state='error']:focus-visible {
  /* Same --err-border as the resting state, not --err — this rule exists
     only to stop the error border reverting to --accent on focus (the
     normal .input:focus rule), not to re-color it again. Using --err here
     shifted the border to a visibly different red on focus, worst in dark
     mode where --err-border is a dark red and --err a light pink. */
  border-color: var(--err-border);
}
.field-hint {
  display: block;
  margin-block-start: var(--space-1);
  font-size: var(--text-xs);
  color: var(--text-muted);
}
.field-hint[data-state='error'] {
  color: var(--err);
}

/* -- Textarea: same visual treatment as Input via the shared .input
   class (see the shared block above) — this only adds what a multi-line
   field needs that a single-line one doesn't. -- */
textarea.input {
  min-height: 96px;
  resize: vertical;
  font-family: inherit;
}

/* -- Input group: wraps an input with a trailing icon-btn (password
   reveal toggle) — position: relative on the wrapper, absolute on the
   button, padding-inline-end on the input so text doesn't run under it. -- */
.input-group {
  position: relative;
  display: inline-flex;
}
.input-group .input {
  flex: 1;
  min-width: 0;
  padding-inline-end: var(--space-8);
}
.input-group .icon-btn {
  position: absolute;
  /* var(--space-3) matches .input's own inline padding — same inset the
     native calendar icon on input[type="date"] sits at, since that icon
     is flush with the padding edge rather than hugging the border. */
  inset-inline-end: var(--space-3);
  inset-block-start: 50%;
  transform: translateY(-50%);
}

/* -- Search field: .input-group's leading-icon counterpart — every
   design system surveyed here ships a dedicated Search input, distinct
   from a generic text field, because the icon needs to sit inside the
   field rather than beside it. The icon is decorative/non-interactive
   (pointer-events: none) so it doesn't steal focus from the input
   itself; a trailing clear button reuses .input-group's existing
   absolute-end .icon-btn unchanged. -- */
.input-group-icon {
  position: absolute;
  inset-inline-start: var(--space-3);
  inset-block-start: 50%;
  transform: translateY(-50%);
  color: var(--text-muted);
  pointer-events: none;
}
.input-group:has(.input-group-icon) .input {
  padding-inline-start: var(--space-8);
}

/* -- Number stepper: an .input flanked by two .icon-btn (decrement/
   increment) — [type="number"]'s native spinner renders inconsistently
   enough across browsers (hidden by default in Firefox, tiny in
   Chromium) that every design system surveyed here ships explicit
   +/- buttons instead of relying on it. Click handling (respecting
   min/max/step) is wired in gallery/shared/demos.js. -- */
.number-stepper {
  display: inline-flex;
  align-items: stretch;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
}
.number-stepper .input {
  width: 4ch;
  border: none;
  border-radius: 0;
  text-align: center;
  -moz-appearance: textfield;
}
.number-stepper .input::-webkit-outer-spin-button,
.number-stepper .input::-webkit-inner-spin-button {
  margin: 0;
  -webkit-appearance: none;
}
.number-stepper .icon-btn {
  border-radius: 0;
  flex-shrink: 0;
  /* .icon-btn's own fixed height (32px) otherwise wins over the container's
     align-items: stretch — a flex item only stretches when its cross-size
     is auto, so without this override the buttons sat flush to the top,
     shorter than the taller .input beside them, instead of centered. */
  height: auto;
}
.number-stepper .icon-btn:first-child {
  border-start-start-radius: var(--radius-md);
  border-end-start-radius: var(--radius-md);
}
.number-stepper .icon-btn:last-child {
  border-start-end-radius: var(--radius-md);
  border-end-end-radius: var(--radius-md);
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* -- Combobox: editable input + filtered listbox popup (WAI-ARIA APG
   "editable combobox with list autocomplete"). Positioned like .menu
   (absolute, anchored below, normal flow so it can't be detached from its
   trigger's containing block the way a real [popover] would) but the
   open/close and aria-activedescendant tracking are JS-driven state, not
   <details> — filtering needs real state <details> doesn't give for free. -- */
.combobox {
  position: relative;
}
.combobox-listbox {
  position: absolute;
  inset-block-start: calc(100% + var(--space-1));
  inset-inline-start: 0;
  inset-inline-end: 0;
  margin: 0;
  max-height: 240px;
  overflow-y: auto;
  padding: var(--space-1);
  border: var(--border-width-thin) solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  box-shadow: var(--shadow-elevation-3);
  color: var(--text);
  list-style: none;
  z-index: var(--z-overlay);
}
.combobox-option {
  padding-block: var(--space-2);
  padding-inline: var(--space-3);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  cursor: pointer;
}
.combobox-option:hover,
.combobox-option[aria-selected='true'] {
  background: var(--surface-sunken);
}
.combobox-empty {
  padding-block: var(--space-2);
  padding-inline: var(--space-3);
  font-size: var(--text-sm);
  color: var(--text-muted);
}

/* -- Date picker: text input + trigger button + popover calendar
   (WAI-ARIA APG "Date Picker Dialog" pattern). Positioned like .combobox
   rather than a true modal <dialog> — a focus-trapped modal is more than
   a calendar popup needs, and top-layer promotion would detach it from
   its trigger the same way [popover] would break .menu's anchoring.
   Single-date only; native <input type="date"> already covers the simple
   case (see README) — this is for when that's not enough. -- */
.datepicker {
  position: relative;
  display: inline-block;
}
.datepicker-trigger {
  position: absolute;
  inset-inline-end: var(--space-1);
  inset-block-start: 50%;
  transform: translateY(-50%);
}
.datepicker-panel {
  position: absolute;
  inset-block-start: calc(100% + var(--space-1));
  inset-inline-start: 0;
  z-index: var(--z-overlay);
  width: 280px;
  padding: var(--space-3);
  border: var(--border-width-thin) solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  box-shadow: var(--shadow-elevation-3);
}
.datepicker-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-block-end: var(--space-2);
}
.datepicker-label {
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
}
.datepicker-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: var(--space-0);
}
/* role="row" wrappers (WAI-ARIA grid requires them between grid/gridcell)
   must not become their own grid track - contents hands their children
   straight to .datepicker-grid's column layout. */
.datepicker-row {
  display: contents;
}
.datepicker-weekday {
  text-align: center;
  padding-block: var(--space-1);
  font-size: var(--text-xs);
  color: var(--text-muted);
}
.datepicker-day {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1 / 1;
  border: none;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--text);
  font-size: var(--text-sm);
}
.datepicker-day:not(:disabled):hover {
  background: var(--surface-sunken);
}
.datepicker-day[data-outside] {
  color: var(--text-muted);
}
.datepicker-day[data-today] {
  font-weight: var(--weight-bold);
}
.datepicker-day[aria-selected='true'] {
  background: var(--accent);
  color: var(--on-accent);
}
.datepicker-day:disabled {
  opacity: var(--opacity-disabled);
  cursor: not-allowed;
}

/* -- Checkbox / Radio: native inputs styled via accent-color, the same
   approach Toggle above uses — no custom-drawn SVG checkmark to keep in
   sync with the platform's own glyph across OSes, and the native focus
   ring keeps working since nothing sets appearance:none. -- */
.checkbox,
.radio {
  accent-color: var(--accent);
  width: 18px;
  height: 18px;
  flex-shrink: 0;
}
.checkbox-row,
.radio-row {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  padding-block: var(--space-2);
}
.checkbox-row .checkbox,
.radio-row .radio {
  margin-block-start: 2px;
}
.checkbox-row-text,
.radio-row-text {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.checkbox-row-label,
.radio-row-label {
  font-weight: var(--weight-semibold);
}
.checkbox-row-hint,
.radio-row-hint {
  font-size: var(--text-sm);
  color: var(--text-muted);
}

/* -- Slider: native <input type="range"> takes accent-color for both track
   fill and thumb in evergreen Chromium/Firefox, so no vendor-prefixed
   ::-webkit-slider-thumb / ::-moz-range-thumb overrides are needed. -- */
.slider {
  accent-color: var(--accent);
  width: 100%;
}

/* -- File uploader: a <label> wrapping a visually-hidden native file input
   (pair with .visually-hidden), so clicking anywhere in the dropzone
   opens the file picker with zero JS. [data-active] (drag-over) is the one
   state that needs a few lines of JS from the consuming page — dragenter/
   dragleave/drop toggling the attribute; see gallery/shared/demos.js for a reference
   wiring. -- */
.uploader {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  padding: var(--space-7);
  border: 2px dashed var(--border-strong);
  border-radius: var(--radius-lg);
  background: var(--surface);
  color: var(--text-muted);
  text-align: center;
  cursor: pointer;
  transition: background-color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.uploader:hover,
.uploader:focus-within {
  border-color: var(--accent);
}
.uploader[data-active] {
  border-color: var(--accent);
  background: var(--accent-soft);
}
.uploader[data-state='error'] {
  border-color: var(--err-border);
  background: var(--err-bg);
}
.uploader-icon {
  color: var(--text-faint);
}
.uploader-title {
  font-weight: var(--weight-semibold);
  color: var(--text);
}
.uploader-hint {
  font-size: var(--text-sm);
}

/* -- Breadcrumbs: an <ol> so screen readers announce position/count; the
   separator is a plain CSS character rather than an icon-sprite <use>
   (::before content can't reference an SVG symbol). -- */
.breadcrumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: var(--text-sm);
}
.breadcrumb-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  color: var(--text-muted);
}
.breadcrumb-item a {
  color: var(--text-muted);
  text-decoration: none;
}
.breadcrumb-item a:hover {
  color: var(--accent);
  text-decoration: underline;
}
.breadcrumb-item + .breadcrumb-item::before {
  content: '/';
  color: var(--text-faint);
}
.breadcrumb-item[aria-current='page'] {
  color: var(--text);
  font-weight: var(--weight-semibold);
}

/* -- Pagination: page buttons are icon-btn-sized targets; the prev/next
   arrows reuse .icon-btn directly in markup instead of a parallel
   button style. .pagination is the flex row (prev button, page list, next
   button); .pagination-list is the actual <ul> of page numbers, kept as
   its own element so demos.js's dynamic windowing (see gallery/shared/
   demos.js) can replace just that list's innerHTML on every page change
   without touching the prev/next buttons around it. -- */
.pagination {
  display: flex;
  align-items: center;
  gap: var(--space-1);
}
.pagination-list {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  list-style: none;
  margin: 0;
  padding: 0;
}
.pagination-item {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  height: 32px;
  padding-inline: var(--space-2);
  border-radius: var(--radius-md);
  border: 1px solid transparent;
  color: var(--text-muted);
  font-size: var(--text-sm);
  transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.pagination-item:not(:disabled):hover {
  background: var(--surface-sunken);
  color: var(--text);
}
.pagination-item[aria-current='page'] {
  background: var(--accent);
  color: var(--on-accent);
}
.pagination-item:disabled {
  opacity: var(--opacity-disabled);
  cursor: not-allowed;
}
.pagination-ellipsis {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  height: 32px;
  color: var(--text-faint);
}

/* -- Drawer: wraps native <dialog>, same as Modal above — free focus trap,
   Esc-to-close, ::backdrop — anchored to an edge and full-height instead of
   centered. [data-side="start"|"end"] picks the edge, default "end". The
   slide-in direction is expressed as translate + :dir(), not a hardcoded
   translateX, so it's still correct under dir="rtl" (see base.css's RTL
   policy) without any JS involvement. -- */
.drawer {
  position: fixed;
  inset-block: 0;
  inset-inline-end: 0;
  margin: 0;
  max-width: min(360px, 90vw);
  width: 100%;
  height: 100%;
  max-height: none;
  border: none;
  border-inline-start: 1px solid var(--border);
  padding: var(--space-6);
  background: var(--surface-raised);
  color: var(--text);
  box-shadow: var(--shadow-elevation-3);
  overflow-y: auto;
  translate: 0 0;
  transition: translate var(--dur-base) var(--ease);
}
.drawer[data-side='start'] {
  inset-inline-end: auto;
  inset-inline-start: 0;
  border-inline-start: none;
  border-inline-end: 1px solid var(--border);
}
@starting-style {
  .drawer[open]:dir(ltr) { translate: 100% 0; }
  .drawer[open]:dir(rtl) { translate: -100% 0; }
  .drawer[open][data-side='start']:dir(ltr) { translate: -100% 0; }
  .drawer[open][data-side='start']:dir(rtl) { translate: 100% 0; }
}

/* -- Card: generic content container. [data-interactive] is for cards that
   are themselves a whole clickable <a>/<button> (not just a card that
   happens to contain a button) — it adds hover affordance for that case. -- */
.card {
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  transition: box-shadow var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.card[data-interactive] {
  cursor: pointer;
}
.card[data-interactive]:hover {
  border-color: var(--border-strong);
  box-shadow: var(--shadow-elevation-2);
}
.card-media {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}
.card-body {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-4);
}
.card-title {
  font-size: var(--text-lg);
  font-weight: var(--weight-bold);
}
.card-text {
  color: var(--text-muted);
  font-size: var(--text-sm);
}
.card-footer {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-4);
  border-block-start: 1px solid var(--border);
}

/* -- Empty state: icon + message (+ optional action) for a list/table with
   nothing to show yet — distinct from Skeleton (which signals "loading",
   not "empty"). Deliberately just a layout shell, since the icon/message/
   action wording always varies per use. -- */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--space-2);
  padding-block: var(--space-9);
  padding-inline: var(--space-4);
  color: var(--text-muted);
}
.empty-state-icon {
  color: var(--text-faint);
  margin-block-end: var(--space-2);
}
.empty-state-title {
  font-weight: var(--weight-semibold);
  color: var(--text);
}
.empty-state-text {
  font-size: var(--text-sm);
  max-width: 40ch;
}

/* -- Avatar: circular image, or initials fallback when there's no image. -- */
.avatar {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  border-radius: var(--radius-full);
  overflow: hidden;
  background: var(--accent-soft);
  color: var(--text);
  font-size: var(--text-sm);
  font-weight: var(--weight-bold);
}
.avatar[data-size='sm'] {
  width: 24px;
  height: 24px;
  font-size: var(--text-xs);
}
.avatar[data-size='lg'] {
  width: 40px;
  height: 40px;
  font-size: var(--text-md);
}
.avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.avatar-status {
  position: absolute;
  inset-block-end: 0;
  inset-inline-end: 0;
  width: 9px;
  height: 9px;
  border-radius: var(--radius-full);
  border: 2px solid var(--surface);
  background: var(--text-faint);
  pointer-events: none; /* same reasoning as .badge above */
}
.avatar-status[data-status='online'] {
  background: var(--ok);
}
.avatar-status[data-status='busy'] {
  background: var(--err);
}

/* -- Tooltip: pure CSS via [data-tooltip], shown on hover AND :focus-visible
   so keyboard users get it too. Background/text are --text/--bg
   (inverted), not hardcoded dark/light values — the same light-dark()
   inversion trick the rest of this file relies on means the bubble is
   correctly high-contrast in both themes with one rule, not two. The bubble
   text is NOT exposed to assistive tech by itself — pair [data-tooltip]
   with an aria-label (icon-only triggers already need one). -- */
[data-tooltip] {
  position: relative;
}
[data-tooltip]::before {
  content: attr(data-tooltip);
  position: absolute;
  inset-block-end: calc(100% + var(--space-2));
  inset-inline-start: 50%;
  translate: -50% 0;
  padding-block: var(--space-1);
  padding-inline: var(--space-2);
  border-radius: var(--radius-sm);
  background: var(--text);
  color: var(--bg);
  box-shadow: var(--shadow-elevation-2);
  font-size: var(--text-xs);
  font-weight: var(--weight-normal);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease);
  z-index: var(--z-overlay);
}
[data-tooltip]:hover::before,
[data-tooltip]:focus-visible::before {
  opacity: 1;
}
[data-tooltip][data-tooltip-position='bottom']::before {
  inset-block-end: auto;
  inset-block-start: calc(100% + var(--space-2));
}
/* -- 'end': to the trigger's inline-end side, vertically centered — for a
   vertical rail (SidebarNav's collapsed icon-only state) where top/bottom
   would land the bubble on top of the item above/below instead of beside
   the trigger. -- */
[data-tooltip][data-tooltip-position='end']::before {
  inset-block-end: auto;
  inset-block-start: 50%;
  inset-inline-start: calc(100% + var(--space-2));
  translate: 0 -50%;
}
/* -- Tooltip portal escape hatch: the CSS-only bubble above is a child of
   its trigger, so any ancestor with overflow:hidden/auto/clip (an
   .accordion-item clipping its own collapse animation, a .scroll-overlay
   list) crops it. There's no CSS-only fix for that - the bubble has to stop
   being a layout descendant of the clipping ancestor. `.tooltip-bubble` is
   the same visual bubble as a real, JS-created element a consumer appends
   to <body> (position: fixed, coordinates set from the trigger's
   getBoundingClientRect() on hover/focus, removed on leave/blur) instead of
   relying on the ::before. Zero-build (vendored CSS-only) consumers get no
   JS from Fernglas, so the wiring itself is necessarily local to the
   consuming project - only the shared look lives here. See HistoryBroom's
   popup.js for a reference implementation. -- */
.tooltip-bubble {
  position: fixed;
  padding-block: var(--space-1);
  padding-inline: var(--space-2);
  border-radius: var(--radius-sm);
  background: var(--text);
  color: var(--bg);
  box-shadow: var(--shadow-elevation-2);
  font-size: var(--text-xs);
  font-weight: var(--weight-normal);
  max-width: min(240px, calc(100vw - var(--space-4)));
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease);
  z-index: var(--z-overlay);
}
.tooltip-bubble[data-show] {
  opacity: 1;
}

/* -- Carousel: CSS scroll-snap does the swiping/paging natively; prev/next
   buttons (reuse .icon-btn in markup, positioned via .carousel-nav)
   need a few lines of JS from the consuming page to call scrollBy() on
   .carousel-track — see gallery/shared/demos.js for a reference wiring. -- */
.carousel {
  position: relative;
}
.carousel-track {
  display: flex;
  gap: var(--space-4);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scroll-padding-inline: var(--space-4);
  padding-block: var(--space-1);
}
.carousel-item {
  flex: 0 0 auto;
  scroll-snap-align: center;
}
/* -- Single-item variant: one full-width slide at a time, same track/nav/
   dots markup — flex-basis 100% is all that changes, so the prev/next
   buttons (positioned against .carousel, not the item) land exactly at the
   slide's left/right edges for free. -- */
.carousel[data-variant='single'] .carousel-item {
  flex: 0 0 100%;
  width: 100%;
}
.carousel-nav {
  position: absolute;
  inset-block-start: 50%;
  translate: 0 -50%;
  background: var(--surface-raised);
  box-shadow: var(--shadow-elevation-2);
}
.carousel-nav[data-direction='prev'] {
  inset-inline-start: var(--space-2);
}
.carousel-nav[data-direction='next'] {
  inset-inline-end: var(--space-2);
}
.carousel-dots {
  display: flex;
  justify-content: center;
  gap: var(--space-2);
  padding-block-start: var(--space-3);
}
.carousel-dot {
  width: 8px;
  height: 8px;
  padding: 0;
  border: none;
  border-radius: var(--radius-full);
  background: var(--border-strong);
  transition: background-color var(--dur-fast) var(--ease);
}
.carousel-dot[aria-current='true'] {
  background: var(--accent);
}

/* -- Alert (persistent banner): distinct from Toast — stays in page flow
   until removed, no auto-dismiss timer, no fixed positioning. Use
   role="alert" for warn/err (assertive) and role="status" for info/ok
   (polite) on the instance — set per message, since urgency depends on
   what it says, not just its color. -- */
.alert {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  padding: var(--space-4);
  border: 1px solid;
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
}
.alert-icon {
  flex-shrink: 0;
  margin-block-start: 2px;
}
.alert-body {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  flex: 1;
}
.alert-title {
  font-weight: var(--weight-bold);
}
.alert-info {
  background: var(--info-bg);
  border-color: var(--info);
  color: var(--text);
}
.alert-info .alert-icon {
  color: var(--info);
}
.alert-ok {
  background: var(--ok-bg);
  border-color: var(--ok);
  color: var(--text);
}
.alert-ok .alert-icon {
  color: var(--ok);
}
.alert-warn {
  background: var(--warn-bg);
  border-color: var(--warn);
  color: var(--text);
}
.alert-warn .alert-icon {
  color: var(--warn);
}
.alert-err {
  background: var(--err-bg);
  border-color: var(--err);
  color: var(--text);
}
.alert-err .alert-icon {
  color: var(--err);
}

/* -- Progress: linear uses native <progress> — accent-color styles the
   filled bar in evergreen browsers, the same free ride as Slider above.
   Spinner is CSS-only, for the indeterminate case a circular ring can't
   come from native <progress>. -- */
.progress {
  accent-color: var(--accent);
  width: 100%;
  height: 8px;
}
/* -- Progress row: pairs .progress with a leading label and trailing
   count — the shape a mastery/skill list needs (label, fill, "7/10").
   The label column defaults to sizing itself to its own text (not an `fr`
   track), so it never stretches. Each row is its own grid though, so when
   several rows sit next to each other (e.g. in a .grid) their label
   columns size independently and the bars end up different widths — set
   --progress-row-label-w (e.g. to the longest label's length in `ch`)
   on a shared ancestor to give every row the same label column, and
   therefore the same bar width, instead. See gallery/components/progress-row.html. -- */
.progress-row {
  display: grid;
  grid-template-columns: minmax(80px, var(--progress-row-label-w, auto)) 1fr auto;
  align-items: center;
  gap: var(--space-3);
}
.progress-row-label {
  font-size: var(--text-sm);
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.progress-row-count {
  font-size: var(--text-xs);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
/* Thicker than the standalone .progress default — a list of rows reads
   better with a more visible fill than a single inline bar needs. */
.progress-row .progress {
  height: 16px;
}
.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid var(--border);
  border-block-start-color: var(--accent);
  border-radius: var(--radius-full);
  animation: spin 700ms linear infinite;
}
.spinner[data-size='sm'] {
  width: 16px;
  height: 16px;
  border-width: 2px;
}
.spinner[data-size='lg'] {
  width: 36px;
  height: 36px;
  border-width: 4px;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* -- Skeleton: loading placeholder shape; [data-shape] switches between the
   three shapes a loading state actually needs — free-form rect is the
   default. -- */
.skeleton {
  background: linear-gradient(90deg, var(--surface-sunken) 25%, var(--border) 50%, var(--surface-sunken) 75%);
  background-size: 200% 100%;
  animation: skeleton-sweep 1.4s ease-in-out infinite;
  border-radius: var(--radius-sm);
}
.skeleton[data-shape='text'] {
  height: 1em;
  border-radius: var(--radius-sm);
}
.skeleton[data-shape='circle'] {
  border-radius: var(--radius-full);
}
@keyframes skeleton-sweep {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

/* -- Timer: a label + tabular-nums value, for anything counting up/down
   (exam clock, session length). [data-warn] switches the value to --err
   — pair with an aria-live region in the consuming markup so screen reader
   users get the same low-time signal sighted users get from color. -- */
.timer {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  font-variant-numeric: tabular-nums;
}
.timer-label {
  font-size: var(--text-sm);
  color: var(--text-muted);
}
.timer-value {
  font-size: var(--text-xl);
  font-weight: var(--weight-bold);
}
.timer[data-warn] .timer-value {
  color: var(--err);
}

/* -- Sparkline: a hand-rolled inline SVG area+line chart for a single
   series — no charting library for one polyline. Build the `d` path
   yourself (width/height-aware, see gallery/components/sparkline.html for the formula) and
   apply these classes to the <path> elements; the <svg> itself just needs
   `class="sparkline"` plus its own width/height/viewBox. -- */
.sparkline {
  display: block;
  width: 100%;
}
.sparkline-area {
  fill: var(--accent-soft);
  stroke: none;
}
.sparkline-line {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2;
}

/* -- Accordion: wraps native <details>/<summary> — free expand/collapse,
   keyboard support, and it's a disclosure widget in the accessibility tree
   for free, the same "wrap the platform" approach Dialog above takes. -- */
.accordion-item {
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface);
  overflow: hidden;
  /* Inside a flex column (.popup-body's real use case per PATTERNS.md),
     flex items shrink by default - without this, an open accordion taller
     than its share of the available space gets squished by the flex
     algorithm instead of the intended behavior (the flex container
     overflows and scrolls). overflow:hidden above then clips the squished
     content instead of raising a scrollbar, silently losing the bottom of
     whatever's open. Only matters inside a flex parent; harmless (a no-op)
     everywhere else. */
  flex-shrink: 0;
}
.accordion-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-4);
  font-weight: var(--weight-semibold);
  cursor: pointer;
  list-style: none;
}
.accordion-trigger::-webkit-details-marker {
  display: none;
}
.accordion-trigger:hover {
  background: var(--surface-sunken);
}
.accordion-icon {
  flex-shrink: 0;
  color: var(--text-muted);
  transition: transform var(--dur-base) var(--ease);
}
.accordion-item[open] .accordion-icon {
  transform: rotate(180deg);
}
.accordion-content {
  padding-block: 0 var(--space-4);
  padding-inline: var(--space-4);
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* -- List: a plain vertical row list — lighter than Table for a settings
   list, contact list, or menu of items with a leading icon/avatar and
   trailing action. [data-interactive] on a row makes it a whole clickable
   item, same convention as Card. -- */
.list {
  display: flex;
  flex-direction: column;
  margin: 0;
  padding: 0;
  list-style: none;
  border: var(--border-width-thin) solid var(--border);
  border-radius: var(--radius-md);
  overflow: hidden;
}
.list-item {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
  padding-block: var(--space-3);
  padding-inline: var(--space-4);
  background: var(--surface);
  color: var(--text);
  text-align: start;
}
/* Opt-in enter/exit animation for rows a consumer adds or removes one at a
   time (a keyword list, a tag list) - not applied by default, since a bare
   full-list re-render (most .list consumers just clear and rebuild every
   row) would replay the entrance on every unrelated row too. Add
   `.list-item-enter` only to the specific newly-inserted <li>; toggle
   `[data-closing]` on the specific <li> being removed and defer the actual
   removal until `animationend` (or var(--dur-fast) as a fallback) - same
   `[data-closing]` convention Toast already uses for its own exit. */
.list-item-enter {
  animation: list-item-in var(--dur-fast) var(--ease);
}
.list-item[data-closing] {
  animation: list-item-out var(--dur-fast) var(--ease) forwards;
  pointer-events: none;
}
@keyframes list-item-in {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
@keyframes list-item-out {
  from {
    opacity: 1;
    transform: translateY(0);
  }
  to {
    opacity: 0;
    transform: translateY(-4px);
  }
}
.list-item + .list-item {
  border-block-start: var(--border-width-thin) solid var(--border);
}
.list-item[data-interactive] {
  cursor: pointer;
}
.list-item[data-interactive]:hover {
  background: var(--surface-sunken);
}
.list-item-icon {
  flex-shrink: 0;
  color: var(--text-muted);
}
.list-item-body {
  display: flex;
  flex-direction: column;
  gap: var(--space-0);
  flex: 1;
  min-width: 0;
}
.list-item-title {
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
}
.list-item-subtitle {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-xs);
  color: var(--text-muted);
}
.list-item-action {
  flex-shrink: 0;
}

/* -- Description list: key/value detail rows on a real <dl>/<dt>/<dd> —
   common in a detail/summary view, distinct from Table (tabular rows of
   the same shape) or List (a scannable row list, not paired fields).
   Stacks below --bp-sm (copy the literal value per the project's
   breakpoint policy). -- */
.description-list {
  display: grid;
  grid-template-columns: minmax(120px, 1fr) 2fr;
  gap: var(--space-2) var(--space-4);
  margin: 0;
}
.description-term {
  font-size: var(--text-sm);
  color: var(--text-muted);
}
.description-detail {
  margin: 0;
  font-size: var(--text-sm);
  color: var(--text);
}
@media (max-width: 479px) {
  .description-list {
    grid-template-columns: 1fr;
    gap: 0;
  }
  .description-term {
    margin-block-start: var(--space-3);
  }
}

/* -- Avatar group: overlapping .avatar stack for "N people" — overflow is
   just another .avatar showing "+N" text, no new sub-component. Later
   DOM siblings paint over earlier ones under a plain negative-margin
   overlap, no z-index needed. -- */
.avatar-group {
  display: flex;
}
.avatar-group .avatar {
  border: 2px solid var(--surface);
}
.avatar-group .avatar:not(:first-child) {
  margin-inline-start: -8px;
}

/* -- FAB: visual only — position:absolute anchored to its own nearest
   positioned ancestor, not position:fixed to the viewport. A page composes
   the actual placement by wrapping its root in position:relative (or
   overriding to position:fixed itself for a true viewport-hover FAB);
   baking viewport-fixed positioning into the component would make it
   un-demoable inside a page that already has other floating UI (this
   gallery, for one — see .toast-host above, which IS fixed, for the
   contrast). -- */
.fab {
  position: absolute;
  inset-block-end: var(--space-4);
  inset-inline-end: var(--space-4);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 56px;
  height: 56px;
  border: none;
  border-radius: var(--radius-full);
  background: var(--btn-primary-bg);
  color: var(--on-accent);
  font-size: var(--text-xl);
  box-shadow: var(--shadow-elevation-3);
  transition: background-color var(--dur-fast) var(--ease);
}
.fab:hover {
  background: var(--btn-primary-hover);
}
.fab[data-size='sm'] {
  width: 40px;
  height: 40px;
  font-size: var(--text-lg);
}
.fab[data-size='lg'] {
  width: 64px;
  height: 64px;
  font-size: var(--text-2xl);
}

/* -- Split button: two .btn elements wrapped in .split-btn — reuses
   Button's color variants/sizes as-is; this only joins them into one visual
   control with a divider. -- */
.split-btn {
  display: inline-flex;
}
.split-btn .split-btn-main {
  border-start-end-radius: 0;
  border-end-end-radius: 0;
}
/* When the trigger opens a menu, it's a <details class="menu-anchor">
   wrapping the actual .btn (a <summary>), not a bare button — .menu-anchor
   is block-level by default and doesn't stretch its child to fill it, so
   without this the trigger renders shorter than split-btn-main. Flexing the
   anchor makes its default align-items: stretch fill the summary to match. */
.split-btn > .menu-anchor {
  display: flex;
}
.split-btn .split-btn-trigger {
  border-start-start-radius: 0;
  border-end-start-radius: 0;
  /* currentColor, not a hardcoded rgb(0 0 0 / …) — a hardcoded dark
     divider was nearly invisible against the dark-mode fill of danger/
     navy/ruby/gold buttons. currentColor here is the button's own text
     color (--on-accent), so the divider stays a visible, consistent
     fraction of it in every variant and theme. */
  border-inline-start: 1px solid color-mix(in srgb, currentColor 25%, transparent);
  padding-inline: var(--space-2);
}

/* -- Popup shell: composed pattern (not a single component) for a
   fixed-size surface that stays put while its content changes - a browser
   extension's action popup, a dashboard's fixed side panel, anything sized
   by its container rather than the viewport. Backported from RootsReady,
   which had independently built this exact shape; generalized here so
   every popup-style consumer shares one header/body/actions skeleton
   instead of re-deriving it per project. Width/height are the consumer's
   call (varies by content) - set them on .popup-shell in the project, not
   here. See PATTERNS.md for the full anatomy and worked example. -- */
.popup-shell {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  overflow: hidden;
}
.popup-header {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  flex-shrink: 0;
  gap: var(--space-2);
}
/* Leading identity slot (icon, badge, flag...) - margin-inline-end: auto
   pushes it clear of the centered .popup-title and keeps
   .popup-header-actions pinned to the trailing edge whether or not a
   leading slot is present. */
.popup-header-lead {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  margin-inline-end: auto;
}
.popup-header-actions {
  display: flex;
  align-items: center;
  gap: var(--space-1);
}
/* Centered over the header via absolute positioning rather than flex order,
   so it stays visually centered regardless of how wide the lead/actions
   slots are - a flex-only centered title shifts off-center the moment
   either side's content is a different width from the other. */
.popup-title {
  position: absolute;
  inset-inline: 0;
  text-align: center;
  pointer-events: none;
  font-size: var(--text-xl);
  font-weight: var(--weight-bold);
}
.popup-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.popup-hint {
  font-size: var(--text-sm);
  color: var(--text-muted);
}
.popup-stats-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-3);
}
/* margin-block-start: auto pins the action row to the bottom of .popup-body
   regardless of how much content sits above it, without a separate fixed/
   sticky footer element. One primary action carries the visual weight;
   secondary/tertiary actions step down to .btn-navy/.btn-ghost - several
   saturated .btn-primary fills stacked together give a screen no
   hierarchy, nothing reads as *the* thing to click. */
.popup-actions {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-block-start: auto;
  flex-shrink: 0;
}

/* -- Settings section: composed pattern for a grouped-preferences screen -
   a run of bordered sections, each holding label+control field rows, with
   an opt-in two-column grid for short dropdown-only sections. Backported
   from RootsReady, which had independently built this exact shape for its
   settings screen. See PATTERNS.md. -- */
.settings-section {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding-block: var(--space-4);
  border-block-end: 1px solid var(--border);
}
.settings-section:last-of-type {
  border-block-end: none;
}
.settings-field {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--space-2);
  padding-block: var(--space-2);
  font-weight: var(--weight-semibold);
}
/* Dropdown-only section: two columns so short fields don't force a long
   page scroll, collapsing to one below 560px. minmax(0,1fr) lets a long
   <option> shrink instead of widening the track and overflowing. */
.settings-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-1) var(--space-5);
}
@media (max-width: 560px) {
  .settings-grid {
    grid-template-columns: 1fr;
  }
}

/* -- Wizard: composed pattern for a multi-step flow (onboarding, a
   step-per-screen form) - a segment-bar progress indicator plus a
   bottom-pinned back/next action row, same margin-block-start: auto pin
   .popup-actions uses. Generalized from Whisplore's native WizardOption/
   WizardProgress kit, which onboarding.tsx itself had already drifted
   from by hand-rolling a second, slightly different dot indicator inline
   instead of reusing it - exactly the kind of drift a shared pattern
   prevents. See PATTERNS.md. -- */
.wizard {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
  max-width: var(--container-sm);
  margin-inline: auto;
}
.wizard-progress {
  display: flex;
  gap: var(--space-1);
}
.wizard-progress-step {
  flex: 1;
  height: 4px;
  border-radius: var(--radius-full);
  background: var(--border);
}
.wizard-progress-step[data-complete] {
  background: var(--accent);
}
.wizard-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.wizard-actions {
  display: flex;
  gap: var(--space-2);
  margin-block-start: auto;
}
.wizard-actions .btn {
  flex: 1;
}

/* -- Data view: composed pattern orchestrating a list/table's
   loading -> empty -> error -> populated states. The states themselves
   are just existing components composed together (guidance lives in
   PATTERNS.md, not new markup here) except two small additions this
   backport actually needed: .skeleton-list/-row wrap the existing
   .skeleton primitive into a ready-made row-shaped loading placeholder,
   and .empty-state gets an [data-variant="error"] recolor instead of
   introducing a second, parallel "error state" component - two screens in
   the same consuming project had already independently invented their own
   vocabulary for this (.empty-msg, .error-state, a bare .spinner), none
   matching each other or Fernglas's real .empty-state. -- */
.skeleton-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.skeleton-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}
.empty-state[data-variant='error'] .empty-state-icon,
.empty-state[data-variant='error'] .empty-state-title {
  color: var(--err);
}

/* -- List-detail: composed pattern for a persistent list panel next to a
   detail panel (a dashboard, an inbox). ponytail: no consuming project has
   built this shape yet - added ahead of need only because it was one of
   the four candidates this audit went looking for, so it's kept
   deliberately minimal (two panes, one responsive collapse rule) rather
   than guessing at features no real screen has asked for. Upgrade when an
   actual consumer needs resizable panes, a third pane, etc. -- */
.list-detail {
  display: grid;
  grid-template-columns: minmax(240px, 320px) 1fr;
  height: 100%;
  overflow: hidden;
}
.list-detail-list {
  border-inline-end: 1px solid var(--border);
  overflow-y: auto;
}
.list-detail-detail {
  overflow-y: auto;
  padding: var(--space-4);
}
/* Below 768px (--bp-md - can't reference a custom property inside a media
   query, see the Breakpoints foundation page), collapse to one visible
   pane at a time; the consumer's own nav state decides which via
   [data-active], not a fixed mobile layout baked in here. */
@media (max-width: 767px) {
  .list-detail {
    grid-template-columns: 1fr;
  }
  .list-detail-list,
  .list-detail-detail {
    display: none;
  }
  .list-detail[data-active='list'] .list-detail-list,
  .list-detail[data-active='detail'] .list-detail-detail {
    display: block;
  }
}
