/* ============================================================
   ds-kit - component styles
   Pair with tokens.css, in that order.
   ============================================================ */

/* ---- Buttons ----
   One height ladder shared with inputs so horizontal toolbars stay aligned
   regardless of label length, icon presence, or element type. The primary
   is outlined: accent never fills area, so coverage stays under 10 percent
   by construction. Press is the jelly squish, a scale settled by a spring,
   no ripple. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  height: var(--ctl);
  min-height: var(--ctl);
  padding: 0 16px;
  font: 500 var(--t-3)/1.2 var(--font-sans);
  vertical-align: middle;
  border: 1px solid transparent;
  border-radius: var(--r-1);
  cursor: pointer;
  transition:
    background var(--dur-tint) var(--ease),
    border-color var(--dur-tint) var(--ease),
    color var(--dur-tint) var(--ease),
    transform var(--press-dur) var(--press-spring);
  white-space: nowrap;
  text-decoration: none;
  user-select: none;
}
input.btn { line-height: 34px; }   /* native inputs need explicit line-height */
.btn svg { width: 14px; height: 14px; flex-shrink: 0; }

.btn-sm {
  height: var(--ctl-sm);
  min-height: var(--ctl-sm);
  padding: 0 12px;
  font-size: var(--t-2);
}
input.btn-sm { line-height: 28px; }

.btn-lg {
  height: var(--ctl-lg);
  min-height: var(--ctl-lg);
  padding: 0 20px;
  font-size: var(--t-4);
}
input.btn-lg { line-height: 42px; }

/* Primary: outlined. Dark: accent-text on the quiet fill, 6.70:1 on --bg,
   5.81:1 on surface-1 (both against the painted fill pixel, re-measured
   2026-08-30). Light: accent-text alone falls to 4.35:1 on --bg,
   so the label takes the tag mix, measured 4.78:1 on --bg, 5.32:1 on surface-1. */
.btn-primary {
  background: var(--accent-quiet);
  color: var(--accent-text);
  border-color: var(--accent-line);
}
/* Light: the label mix is deeper than the tag's 70/30 because the hover fill
   sits under it. Measured on the composited fill (24 percent of the light
   accent over the page is #B6D4E6): the 70/30 label lands 4.21:1, under the
   floor, and every lighter mix is worse, because the fill is the light thing
   the label mixes toward. Measured on the composited fill through the painted
   pixels: 55/45 leaves the active state at 4.47:1, a hair under; 50/50 clears
   the floor in all three states (rest 6.0, hover 5.0, active 4.6). */
[data-theme="light"] .btn-primary { color: color-mix(in srgb, var(--accent) 50%, var(--text) 50%); }
.btn-primary:hover {
  background: color-mix(in srgb, var(--accent) 24%, var(--tint-ground));
  border-color: color-mix(in srgb, var(--accent) 60%, var(--tint-ground));
}
/* the press tint holds the dark label at 4.60:1 on surface-1 and 5.21 on --bg
   (8-bit painted); 32 percent dropped it to 4.15 on the card, and 28 to 4.48 */
.btn-primary:active {
  background: color-mix(in srgb, var(--accent) 27%, var(--tint-ground));
  border-color: color-mix(in srgb, var(--accent) 60%, var(--tint-ground));
}

/* Filled: the ONE main action on a screen, and never a second one. Outlined
   primary is still the default, so accent coverage stays under 10 percent by
   construction; this is the same once-per-screen rule --accent-2 lives under.
   Label on the fill, measured: dark 8.22:1 at rest, 10.03 hover, 6.60 press;
   light 4.60 / 5.90 / 7.32. This is the only pair that reads --accent-hover
   and --accent-press, which is what those two tokens are for. */
.btn-fill {
  background: var(--accent);
  --tint-ground: transparent;
  color: var(--on-accent);
  border-color: var(--accent);
}
.btn-fill:hover  { background: var(--accent-hover); --tint-ground: transparent; border-color: var(--accent-hover); }
.btn-fill:active { background: var(--accent-press); --tint-ground: transparent; border-color: var(--accent-press); }
/* An accent fill swallows an accent focus ring, so the ring's own page keyline
   is what separates the two. tokens.css ships it on :focus-visible. */

.btn-secondary {
  background: transparent;
  color: var(--text-2);
  border-color: var(--surface-3);
}
.btn-secondary:hover { background: var(--surface-2); --tint-ground: transparent; color: var(--text); border-color: var(--surface-3); }
.btn-secondary:active { background: var(--surface-3); --tint-ground: transparent; }

.btn-danger {
  background: transparent;
  color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 40%, var(--tint-ground));
}
/* label and tint are one hue, so a deeper tint pulls the label down with it:
   the hover and press label steps to --danger-text, measured in tokens.css */
.btn-danger:hover {
  background: color-mix(in srgb, var(--danger) 16%, var(--tint-ground));
  color: var(--danger-text);
  border-color: color-mix(in srgb, var(--danger) 60%, var(--tint-ground));
}
.btn-danger:active {
  background: color-mix(in srgb, var(--danger) 24%, var(--tint-ground));
  color: var(--danger-text);
}

.btn-icon {
  width: var(--ctl);
  min-width: var(--ctl);
  padding: 0;
  background: transparent;
  color: var(--text-3);
  border-color: var(--line);
}
.btn-icon:hover { border-color: var(--surface-3); color: var(--text); background: var(--surface-2); --tint-ground: transparent; }
.btn-icon svg { width: 15px; height: 15px; }

/* ---- Disabled ----
   AFTER EVERY VARIANT, because it has to outrank each one's own background and
   colour. Source order is the mechanism only INSIDE this block, between
   `.btn:disabled` and the `.btn-*:disabled` rows under it, which do all tie at
   (0,2,0); the variants themselves are single-class selectors at (0,1,0) that
   `.btn:disabled` already outranks. What no placement here can beat is a state
   costume at (0,3,0), which is why the `!important` block near the end of the
   file exists.
   It replaces `opacity: 0.45`, her call 2026-09-02. An alpha dims the label with
   the box, and a label dimmed that far stops clearing any floor; a colour dims
   only what should read as off. --text-disabled measures 4.50:1 on --bg and 3.89
   on --surface-1, both over the 3:1 floor for a control that cannot act.
   A filled button KEEPS A FILL. The silhouette is what says which control is the
   primary, so dropping it turns a disabled primary into a disabled secondary;
   --surface-2 is the fill, and --text-disabled holds 3.29:1 on it dark and 3.54
   light. */
.btn:disabled {
  cursor: not-allowed;
  pointer-events: none;
}
/* EVERY variant that paints a fill or an edge is listed, derived from the rules
   rather than from the one specimen a page happens to show: .btn-primary paints
   --accent-quiet under an --accent-line edge, and with only `color` overridden a
   disabled primary kept both and read as live. .btn-icon needs no row, since its
   background is already transparent and its edge is already --line.
   Each keeps its own silhouette on the neutral ramp: the filled one stays a solid
   block, the primary keeps a fill under a visible edge (--surface-3 on
   --surface-2, 1.23:1 dark and 1.10 light, the same register the enabled edges
   sit in), the two outlined ones keep an edge and no fill. */
.btn-fill:disabled { background: var(--surface-2); --tint-ground: transparent; border-color: var(--surface-2); }
.btn-primary:disabled { background: var(--surface-2); --tint-ground: transparent; border-color: var(--surface-3); }
.btn-secondary:disabled, .btn-danger:disabled { border-color: var(--line); }

/* Loading spinner */
@keyframes spin { to { transform: rotate(360deg); } }
/* the ring's loading breath: head anchored at 50, so the offset tracks L - 50 */
@keyframes ring-tail-breathe { to { stroke-dasharray: 46 100; stroke-dashoffset: -4; } }
/* its reduced-motion replacement, on the shape of the route bar's own hold */
@keyframes ring-hold { 50% { opacity: var(--ring-hold-dim); } }
.btn-loading { position: relative; color: transparent !important; }
.btn-loading::after {
  content: '';
  position: absolute; inset: 0; margin: auto;
  width: 12px; height: 12px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
  color: var(--accent-text);
}
/* a filled button's ground is --accent, where that --accent-text ring is
   invisible (1.22:1 dark rest, 1.00:1 hovered): the fill's spinner takes the
   fill's own on-colour, 8.22:1 dark / 4.60:1 light */
.btn-fill.btn-loading::after { color: var(--on-accent); }
@media (prefers-reduced-motion: reduce) {
  /* the busy cue replaces the motion rather than dying with it: the ring
     rests, so the button still reads as busy without a spin */
  .btn-loading::after { animation: none; }
}

/* ---- Page skeleton ----
   Built independently by every dashboard project on the system: one
   page column, section headers as a triplet, toolbars that compose.
   Under a navbar, retune a build with --page-w, not per-page overrides. */
.page {
  max-width: var(--page-w);
  /* auto inline margins cancel a grid or flex item's stretch, so the column
     sizes to its content: measured 99px for a one-word page in both shipped
     shells. The stated width is what keeps it a column rather than a label. */
  width: 100%;
  margin-inline: auto;
  padding-inline: var(--space-8);
}
@media (max-width: 768px) { .page { padding-inline: var(--space-4); } }
/* Beside a sidebar alone a cap here centres nothing; --page-w is past the
   reading-column bound shells.md names. Measured 230px of dead page each side
   on the dashboard example at 1912. A bleed banner's column follows the page's. */
.sidebar ~ .page,
.sidebar ~ * .page,
.sidebar ~ * .banner-inner { max-width: none; }

/* Eyebrow is .label; the triplet continues with title and lede.
   The title is --t-8, two steps above the document h2, because a section
   heading and a paragraph heading are not the same object: h2 stays 26px for
   prose. It steps to --t-7 under 768px as the page title steps to --t-8, so
   the two stay one step apart at every width. The lede is --t-4, so it stops sitting at body size and the page
   stops reading at one volume. The eyebrow is --text-4 (5.47:1 dark, 4.94:1
   light on --bg): accent on every eyebrow down a page turns the signature
   into wallpaper, which is the failure v1 shipped. */
.section-head { margin-bottom: var(--space-5); }
.section-head > .label { color: var(--text-4); }
.section-title {
  font: 600 var(--t-8)/1.05 var(--font-sans);
  letter-spacing: -0.035em;
  color: var(--text);
  margin-top: var(--space-2);
}
@media (max-width: 768px) { .section-title { font-size: var(--t-7); } }
.section-lede {
  font-size: var(--t-4);
  color: var(--text-3);
  margin-top: var(--space-3);
  max-width: var(--page-narrow);
}

.toolbar {
  display: flex;
  align-items: flex-end;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.toolbar .field { min-width: 160px; }
.toolbar .grow { flex: 1 1 auto; }
/* flex-end lines a row of labelled fields up on their controls, and leaves a row
   of bare buttons hanging off the bottom of the tallest thing beside it. Both are
   one class, so the source order here is what decides the override. */
.toolbar-center { align-items: center; }

/* ---- Tabs (underline) ---- */
/* The hairline and the positioning context are the ROW's, not a wrapper's:
   the ink is absolutely placed against the row's own bottom edge, so it lands
   on the hairline it belongs to wherever the row sits. `.tabs-wrap` keeps no
   rule of its own and survives as initTabs' query hook. */
.tabs {
  display: flex;
  gap: 0;
  position: relative;
  border-bottom: 1px solid var(--line-soft);
}
.tab {
  position: relative;
  padding: 9px 14px;
  font: 500 var(--t-3)/1.2 var(--font-sans);
  color: var(--text-4);
  background: transparent;
  border: none;
  cursor: pointer;
  transition: color var(--dur-tint) var(--ease);
  user-select: none;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}
.tab:hover { color: var(--text-2); }
.tab.active { color: var(--accent-text); }

/* The ink slides AND resizes on one transform: translateX carries the position,
   scaleX carries the length, so the two cannot land on different curves. The bar
   is laid out at --ink-base and scaled from its left edge; a separate width
   transition is what made the length snap to the target while the bar was still
   travelling. The pill caps clamp to 1px at this height, so scaleX cannot
   visibly distort them. */
/* One underline for the tabs and the navbar: the same thirteen declarations
   shipped under two names, and the reduced-motion block already treated them
   as one element. */
.tab-ink, .navbar-ink {
  --ink-base: 100px;
  position: absolute;
  bottom: -1px;
  left: 0;
  height: 2px;
  width: var(--ink-base);
  transform: scaleX(0);
  transform-origin: left center;
  background: var(--accent);
  --tint-ground: transparent;
  border-radius: var(--r-pill);
  pointer-events: none;
  opacity: 0;
  transition:
    transform var(--dur-slide) cubic-bezier(0, 0.85, 0.35, 1),
    opacity var(--dur-near) var(--ease);
}
/* The ink always rides translate3d: the z holds its own layer for the life of
   the page, so the sub-pixel geometry painted during the slide is the same
   geometry painted after it. A layer dropped at transitionend re-rasterizes
   the fractional size and position at whole pixels, which is the visible
   snap after landing. */

/* One count chip for a tab and a navbar link. `.nav-badge` is NOT this one:
   mono face, its own padding, its own transition. */
.tab-badge, .navbar-badge {
  font: 500 10px/1 var(--font-sans);
  padding: 2px 5px;
  border-radius: var(--r-pill);
  background: var(--surface-2);
  --tint-ground: transparent;
  color: var(--text-3);
}
/* light takes the shared remix: --accent-text alone reads 4.35:1 over --bg */
.tab.active .tab-badge,
.navbar-link.active .navbar-badge { background: var(--accent-quiet); color: var(--accent-text); }

.tab-panel { display: none; padding-top: var(--space-5); }
/* display swaps retrigger the animation, so the panel settles instead of popping */
.tab-panel.active { display: block; animation: panel-in var(--dur-near) var(--ease-out); }
@keyframes panel-in { from { opacity: 0; transform: translateY(4px); } }

/* ---- Tabs (boxed segmented control) ----
   The selection slides: one ink pill rides the track, the active segment
   is transparent above it. Without JS the active segment paints itself,
   so no-JS keeps a visible selection. */
.tabs-boxed-wrap {
  background: var(--well);
  --tint-ground: transparent;
  border-radius: var(--r-2);
  padding: 3px;
  display: inline-flex;
  gap: 2px;
  position: relative;
}
.tabs-boxed-wrap .tab-box { position: relative; }
.tab-box {
  padding: 6px 12px;
  font: 500 var(--t-2)/1.2 var(--font-sans);
  color: var(--text-3);
  background: transparent;
  border: none;
  /* concentric with the track: track radius minus its 3px padding */
  border-radius: calc(var(--r-2) - 3px);
  cursor: pointer;
  transition: background var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease), transform var(--press-dur) var(--press-spring);
  user-select: none;
}
.tab-box:hover { background: var(--surface-2); --tint-ground: transparent; color: var(--text-2); }
.tab-box.active { color: var(--text); }
/* The segment and the pill that slides over it are ONE selection surface, so one
   rule paints it. Without ink the segment carries it; with ink the pill does and
   .has-ink below turns the segment's copy off. */
.tab-box.active,
.tabs-boxed-ink {
  background: var(--surface-1);
  --tint-ground: transparent;
  box-shadow: var(--lit-1), 0 1px 3px oklch(0.14 0.02 280 / 0.45);
}
[data-theme="light"] .tab-box.active,
[data-theme="light"] .tabs-boxed-ink { box-shadow: var(--lit-1), 0 1px 3px oklch(0.55 0.03 275 / 0.20); }
.tabs-boxed-ink {
  position: absolute;
  top: 3px;
  left: 0;
  height: calc(100% - 6px);
  width: 0;
  border-radius: calc(var(--r-2) - 3px);
  pointer-events: none;
  opacity: 0;
  transition:
    transform var(--dur-slide) cubic-bezier(0, 0.85, 0.35, 1),
    width var(--dur-slide) cubic-bezier(0, 0.85, 0.35, 1),
    opacity var(--dur-near) var(--ease);
}
/* composited like .tab-ink: translate3d, never a layer dropped at landing */
/* once the ink exists, the segment stops painting its own selection */
.tabs-boxed-wrap.has-ink .tab-box.active { background: transparent; box-shadow: none; }

/* ---- Press ----
   The jelly squish (2026-10-07 combo): hover grows, press flattens wider and
   shorter, and --press-spring settles the wobble over --press-dur. Only the
   transform moves on the spring: .btn keeps its background, border-color and
   color on --dur-tint, .tab-box its background and color, and neither
   transitions box-shadow. The hover grow is fine-pointer only; the press squash
   still fires on a tap. None of it under reduced motion, where the fades stay.
   The non-uniform press scale momentarily distorts a control's corner radius,
   which is the point of a squish; references/motion.md sanctions it. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .btn:not(:disabled):hover,
  .tab-box:not(:disabled):hover {
    transform: scale(1.03);
  }
}
/* after the hover grow: both rank (0,3,0), so source order hands a held
   press the squash */
@media (prefers-reduced-motion: no-preference) {
  .btn:not(:disabled):active,
  .tab-box:not(:disabled):active {
    transform: scale(1.05, 0.90);
  }
}

/* ---- Inputs ----
   Inputs sit below the page plane: --well, not raised. Most systems raise
   inputs; ds-kit sinks them. Field labels are 12px 500, sentence case:
   the tracked caps treatment is reserved for eyebrows, table headers and
   navigation-list group labels (sidebar, palette). */
.field { display: flex; flex-direction: column; gap: var(--space-2); }
.field-label {
  font: 500 var(--t-2)/1.2 var(--font-sans);
  color: var(--text-3);
}
.field-hint { font-size: var(--t-2); color: var(--text-4); }
.field-error {
  font-size: var(--t-2);
  color: var(--danger);
  display: flex;
  align-items: center;
  gap: var(--space-1);
}

/* ---- Form grid ----
   Two-column field rows sharing one label column: every .field subgrids
   into the grid's own columns, so labels align across rows with no
   per-field markup and no measured width. Without subgrid the .field
   falls back to its own stacked column, which is the mobile shape. */
.form-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
  column-gap: var(--space-5);
  row-gap: var(--space-4);
  /* A grid stretches its items, and a stretched label paints its one line at
     the TOP of a 36px control's row: measured 11px above the control's centre,
     against 0.2px for the same label inside a .field, which centres its own
     subgrid. The bare pair is the shape the fieldset and half the sheet use. */
  align-items: center;
}
.form-grid .field {
  display: grid;
  /* the span is restated here rather than taken from the child rule below:
     subgrid needs it, and the scope is descendant so a field a wrapper div
     sits between still levels with the grid's own columns */
  grid-column: 1 / -1;
  grid-template-columns: subgrid;
  align-items: center;
}
.form-grid .field-label { grid-column: 1; }
.form-grid .field > :not(.field-label) { grid-column: 2; }
/* Everything that is not one of the bare label/control pairs spans both
   columns: a .field, a summary, a fieldset, a paragraph, a callout. Left to
   `auto` the fieldset was placed in the 180px label column and its nested
   grid crushed with it, the value clipping at "oncall@acm". The pair is
   restated structurally one line down, so a child joins by being a label's
   sibling rather than by being named. */
.form-grid > * { grid-column: 1 / -1; }
.form-grid > .field-label + * { grid-column: 2; }

/* ---- Fieldset ----
   A grouped border, not a second card: line-soft, no shadow, the legend
   in sentence case. Tracked caps stay on their sanctioned places
   and a legend is none of them. */
.fieldset {
  min-width: 0;
  margin: 0;
  /* Block padding only. Inline padding offsets a nested .form-grid, so the
     labels inside a group stop lining up with the labels outside it: measured
     21px apart. The border still reads without it. */
  padding: var(--space-5) 0;
  border: 1px solid var(--line-soft);
  border-inline: none;
  border-radius: 0;
}
.fieldset > .form-grid { margin-block-start: var(--space-4); }
.fieldset legend {
  /* the 8px is the gap the legend cuts in the top border, and it is pulled
     straight back out so the first glyph lands on the label column the group's
     own fields use: with the padding alone the two measured 312 against 304 */
  padding: 0 var(--space-2);
  margin-inline-start: calc(-1 * var(--space-2));
  font: 500 var(--t-3)/1.2 var(--font-sans);
  color: var(--text-2);   /* 7.30:1 on --surface-1 dark, 8.36:1 light */
}

/* ---- Validation summary ----
   Submit failures land here before the fields: the count, then one link
   per invalid field. The link carries the fix, not the field name alone.
   It IS the danger callout: the shell, the tint, the icon slot and the title
   all come from the callout block, and only the entrance is its own. */
.form-summary {
  /* an alert that pops in reads as scolding; a short fade-and-settle lands it */
  animation: panel-in var(--dur-near) var(--ease-out);
}
.form-summary-icon { color: var(--danger); }   /* 4.74:1 dark, 5.47:1 light on the tint */
.form-summary-links {
  margin: var(--space-1) 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.form-summary-links a { font-size: var(--t-2); color: var(--accent-text); }   /* 7.25:1 dark, 5.17:1 light on the tint */

/* Stacked under 768px: labels above fields, the .field's own shape. The pair
   rule above names column 2, which a one-column grid does not have, so the
   control returns to flow or it lands in an implicit second column. */
@media (max-width: 768px) {
  .form-grid { grid-template-columns: minmax(0, 1fr); }
  .form-grid > .field-label + * { grid-column: auto; }
}

/* ---- Setting rows ----
   The settings panel's one layout: label, description and control on a
   single row, a divider between rows, stacked below 768px. Hover paints
   the row the hover surface. A disabled control dims the text ramp
   instead of the whole row: the description stays at reading contrast,
   because it is the reason the control is off. */
.setting-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas:
    "label control"
    "desc control";
  column-gap: var(--space-6);
  row-gap: var(--space-1);
  align-items: center;
  padding: var(--space-4) var(--space-5);
  border-radius: 0;
  transition: background var(--dur-tint) var(--ease);
}
/* the stack is flush on both axes, because a card holding nothing but these
   rows drops its own padding where `.card` is defined, so the edge rows take
   the card's radius and the hover fill reaches its edge. Longhands, not the
   shorthand: the two rules tie at (0,2,0) and a card with ONE row matches both,
   so a shorthand pair hands it the later rule's square top corners, which used
   to sit 20px inside the card and now sit on it. */
.setting-row:first-child {
  border-start-start-radius: var(--r-2);
  border-start-end-radius: var(--r-2);
}
.setting-row:last-child {
  border-end-start-radius: var(--r-2);
  border-end-end-radius: var(--r-2);
}
.setting-row + .setting-row { border-top: 1px solid var(--line-soft); }
.setting-row:hover { background: var(--surface-2); --tint-ground: transparent; }

.setting-label {
  grid-area: label;
  font: 500 var(--t-3)/1.3 var(--font-sans);
  color: var(--text-2);   /* dark 7.30:1 on --surface-1, 6.17:1 on the hover; light 8.36 / 7.00 */
}
.setting-desc {
  grid-area: desc;
  font-size: var(--t-2);
  color: var(--text-3);   /* dark 5.49:1 on --surface-1, 4.65:1 on the hover; light 5.92 / 4.96 */
}

/* the control slot: anything from the height ladder drops in aligned */
.setting-control {
  grid-area: control;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  min-width: 0;
}
/* .input is width:100% for stacked fields; in a row it takes its own width.
   The select trigger is not a bare input: it fills its `.select` container,
   which is the element that carries the width. Letting the bare-input rule
   hit it left a 160px trigger under a 200px panel, the dropdown wider than
   the button that opened it. */
.setting-control .input:not(.select-trigger) { width: auto; min-width: 160px; }
.setting-control .select { width: 200px; }

/* the row is the target: a row whose control is a toggle switches from
   anywhere on the row, guarded in JS against clicks on other controls */
.setting-row:has(> .setting-control > .toggle:not(:disabled)) { cursor: pointer; }

/* the row does not read as actionable when its control cannot act */
.setting-row:has(:disabled, [aria-disabled="true"]):hover { background: transparent; }
/* BOTH LINES TAKE ONE TONE. The row used to step each line down by one, which
   put the disabled label on --text-3, the exact colour an enabled description
   wears two lines below it: a one-step drop onto a live colour is not a state.
   Size and weight already carry the hierarchy inside the row, so colour is free
   to say only "off". --text-disabled on --surface-1: 3.89:1 dark, 4.23 light. */
.setting-row:has(:disabled, [aria-disabled="true"]) :is(.setting-label, .setting-desc) {
  color: var(--text-disabled);
}

/* Stacked under 768px: the control follows the description, full width */
@media (max-width: 768px) {
  .setting-row {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "label"
      "desc"
      "control";
    row-gap: var(--space-3);
  }
  .setting-control { justify-content: flex-start; }
  .setting-control .input:not(.select-trigger),
  .setting-control .select { width: 100%; min-width: 0; }
}

.input {
  background: var(--well);
  --tint-ground: transparent;
  border: 1px solid var(--line);
  border-radius: var(--r-1);
  height: var(--ctl);
  padding: 0 12px;
  font: 400 var(--t-3)/1.4 var(--font-sans);
  color: var(--text);
  width: 100%;
  /* the focus ring as state rather than a value: a control that has to
     interpolate to it, like `.select`'s seal, then cannot land anywhere else */
  --ring: 0px;
  --ring-color: var(--accent-line);
  box-shadow: 0 0 0 var(--ring) var(--ring-color);
  transition:
    border-color var(--dur-tint) var(--ease),
    box-shadow var(--dur-tint) var(--ease);
}
.input::placeholder { color: var(--text-placeholder); }   /* 4.99:1 dark, 4.52:1 light on --well */
.input:hover:not(:focus):not(.input-invalid):not([aria-invalid="true"]) { border-color: var(--surface-3); }
.input:focus {
  outline: none;
  border-color: var(--accent);
  --ring: 3px;
}
/* aria-invalid drives the same styling, so the visual state cannot drift
   from what assistive tech reads. */
.input-invalid, .input[aria-invalid="true"] {
  border-color: var(--danger);
  --ring-color: color-mix(in srgb, var(--danger) 30%, var(--glass-ground));
}
.input-invalid:focus, .input[aria-invalid="true"]:focus { border-color: var(--danger); }
/* every input-family control shares the disabled costume; before this the
   system shipped one for .btn only. The VALUE dims and the box does not: a field
   that loses its well and its hairline stops reading as a field, and the state
   is already carried by the value, the missing focus ring and the cursor.
   --text-disabled measures 4.99:1 on --well dark and 3.44 light, against --text's
   12.98 and 10.95, so the step is unmistakable and still over the 3:1 floor.
   The toggle is a graphic and takes its own costume, below. */
.input:disabled, .select-trigger:disabled {
  color: var(--text-disabled);
  cursor: not-allowed;
  pointer-events: none;
}
/* the caret names its own --text-4, so the trigger's colour never reaches it and
   a disabled select kept a lit caret beside a dimmed value. The combobox has its
   own rule for the same pair, because there the caret sits OUTSIDE the trigger. */
.select-trigger:disabled .select-caret { color: var(--text-disabled); }
/* the hint takes the disabled step with the value. Left at --text-placeholder an
   EMPTY disabled field showed brighter type than a filled one: 4.52:1 light
   against the value's 3.44. Dark is a no-op, the two tokens share a value. */
.input:disabled::placeholder { color: var(--text-disabled); }

textarea.input { resize: vertical; min-height: 80px; line-height: 1.55; padding: 10px 12px; }

select.input {
  appearance: none;
  padding-right: 32px;
  cursor: pointer;
  background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239497AB' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 12px center;
}
[data-theme="light"] select.input {
  background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%23676A74' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}

/* Every number field is a numeric readout, so the mono face and tabular
   figures are the type's own costume, not the stepper's: a bare
   `.input[type=number]` in a form reads the same rule. The spinners go for
   the reason `.select` exists: they are UA chrome, they ignore the theme, and
   they put a second pair of arrows over the ring cursor. Alignment stays with
   the field: the stepper centres its value because the controls flank it, and
   a form field keeps the start edge. `inputmode=numeric` is markup, not CSS. */
.input[type="number"] {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
  appearance: textfield;
}
.input[type="number"]::-webkit-outer-spin-button,
.input[type="number"]::-webkit-inner-spin-button {
  appearance: none;
  margin: 0;
}

/* ---- Select (custom dropdown) ----
   A native <select> styles its box and nothing else: the popup is OS chrome, so
   it ignores the theme and puts the OS arrow back on screen over the ring
   cursor. `.select` is the same control built from real elements, so both stay.

   Open reads as one body lifting off the page. The trigger's bottom corners
   flatten into the panel's top edge and the fill rises --well -> --surface-1
   while the list slides down out of the seam. Closing folds the list back into
   the button, and the button's mouth shuts around the last of it.

   The trigger is `.input .select-trigger`, so it inherits the height ladder,
   the focus ring, the invalid state and the coarse-pointer bump. Options are
   `.menu-item`, the same rows the popover menu uses. */

/* --fold is the whole animation: 0 is open, 1 is folded away. It sits on the
   root and inherits, so the panel and the button read one number. Every part
   of the body is then at the same point of the same move by construction.

   The panel TRAVELS into the seam and the clip's top edge counter-travels to
   stay on it, so the rows move and are swallowed by the button, first row
   first. Clipping alone leaves every row pinned where it was and eats the
   panel from the bottom, which retires the LAST option first and reads as a
   curtain rather than as a list going anywhere.

   The clip's top edge is expressed against the panel's own travel, so the
   shadow's clearance never sits inside that range. Inside it, the clearance
   would own 28 percent of the travel while painting nothing, and every
   property keyed on that progress would run ahead of the panel. */
@property --fold { syntax: "<number>"; inherits: true; initial-value: 1; }

.select {
  position: relative;
  --fold: 1;
  /* The list's visibility is a window of the travel: it fades out over the
     first 28.6 percent of the fold and in over the last 28.6 percent of the
     open, so most of the move reads as a hint instead of a fully-drawn list.
     The seal is the inverse of that window, so the mouth shuts as the list
     fades away and opens as it fades in, one number for both. */
  --fade: clamp(0, (1 - var(--fold)) * 3.5, 1);
  --seal: calc(1 - var(--fade));
  /* the fill has no edge to meet, so it takes the near half of the travel in
     each direction and lands with the seal */
  --lift: clamp(0, (1 - var(--fold)) * 2, 1);
  /* the colour the open body outlines in; the panel reads it too */
  --open-edge: var(--accent);
  /* THE EXIT ACCELERATES AWAY, and --ease cannot: the panel is only drawn while
     --fold is under 0.286 (--fade above), so whatever time the curve spends
     there is the whole close. --ease is cubic-bezier(0.2, 0, 0, 1) and reaches
     0.286 at 13 percent of the duration, which left the panel 14ms of a 108ms
     close and read as a snap with nothing playing: measured on painted frames,
     at --fold 0.701 the panel is already gone. The open is fine on the same
     window because --ease-out front-loads it from the other end and hands the
     fade-in ~160ms of its 180. This curve holds --fold under 0.286 for about 60
     percent of the close, so the panel is drawn while it travels. */
  transition: --fold var(--dur-out-near) cubic-bezier(0.55, 0, 1, 0.45);
}
/* --dur-near, not --dur-far: the ladder puts 122px of travel at 260ms, and at
   260ms this panel reads slow. Her call, 2026-08-29. */
.select.open { --fold: 0; transition: --fold var(--dur-near) var(--ease-out); }
/* an invalid field stays marked while it is open to be corrected, body and
   panel both. As a token swap it cannot be outranked by the open outline,
   which is what a competing rule did. */
.select:has(> .select-trigger:is(.input-invalid, [aria-invalid="true"])) {
  --open-edge: var(--danger);
}

.select-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding-right: 10px;
  text-align: left;
  cursor: pointer;
  /* Where the outline settles once the button is shut; the ring it settles to
     is `.input`'s own `--ring`. The seal interpolates to exactly those, so the
     frame `.closing` drops is the frame nothing changes. */
  --edge: var(--line);
}
.select-trigger:focus { --edge: var(--accent); }
.select-trigger:is(.input-invalid, [aria-invalid="true"]) { --edge: var(--danger); }
/* `.select-value` truncates on `tokens.css`'s `.truncate` rule, which names it. */
/* Square, so the sprite's 16-viewBox symbol drops in: her call 2026-09-02, over
   keeping a hand-drawn 10 x 6 chevron nothing could check. 14px is the ladder's
   `.icon-sm` step, the one that sits beside 14px text. The glyph inside the box
   is 8.75 x 4.4 against the old 8 x 4, so the caret reads the same size and the
   box around it is what grew. */
.select-caret {
  width: 14px;
  height: 14px;
  flex-shrink: 0;
  color: var(--text-4);
  transition: transform var(--dur-near) var(--ease), color var(--dur-tint) var(--ease);
}
.select-trigger:hover .select-caret { color: var(--text-3); }

/* Open IS the focus state: the panel cannot be open unless the trigger holds
   focus. So the accent outlines the whole body instead of ringing the trigger
   alone, which is what `.input:focus`'s 3px halo would do, straight across the
   seam the corners just erased. The seam itself stays --line.

   Every value here is a function of --fold, so the button's move IS the list's
   move: the mouth shuts around the last 15px and is closed the frame the list
   is gone. A costume held to the end of the fold and released afterwards leaves
   the button flat-bottomed with no bottom edge at all and nothing under it; one
   released on a clock of its own draws a rounded corner, or a halo, over a
   panel still on screen. Both were shipped, and both read as two objects. */
.select:is(.open, .closing) > .select-trigger {
  background: color-mix(in oklab, var(--well), var(--surface-1) calc(var(--lift) * 100%));
  --tint-ground: transparent;
  border-color: color-mix(in oklab, var(--open-edge), var(--edge) calc(var(--seal) * 100%));
  border-bottom-color: color-mix(in oklab, var(--line), var(--edge) calc(var(--seal) * 100%));
  box-shadow: 0 0 0 calc(var(--seal) * var(--ring)) var(--ring-color);
  border-bottom-left-radius: calc(var(--seal) * var(--r-1));
  border-bottom-right-radius: calc(var(--seal) * var(--r-1));
  transition: none; /* the fold is the clock; a transition here is a second one */
}
.select:is(.open, .closing).select-up > .select-trigger {
  border-radius: calc(var(--seal) * var(--r-1)) calc(var(--seal) * var(--r-1)) var(--r-1) var(--r-1);
  border-top-color: color-mix(in oklab, var(--line), var(--edge) calc(var(--seal) * 100%));
  border-bottom-color: color-mix(in oklab, var(--open-edge), var(--edge) calc(var(--seal) * 100%));
}
.select.open .select-caret,
.daterange.open .select-caret { transform: rotate(180deg); }
.select.closing .select-caret {
  transition: transform var(--dur-out-near) var(--ease), color var(--dur-tint) var(--ease);
}

.select-menu {
  /* --elev-float reaches 30px below the box and 16px to each side, measured
     on its widest coat (0 14px 22px -10px). The clip has to open past that or
     the reveal shaves the shadow off the panel it just drew. 48px is the
     bleed rather than 30 so a deeper float can land without reopening this
     rule. It closes on the seal, so
     the shadow retires as the mouth shuts rather than switching off with the
     panel's own visibility. */
  --menu-bleed: calc(48px * (1 - var(--seal)));
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 60;
  background: var(--surface-1);
  --tint-ground: transparent;
  border: 1px solid var(--line);
  border-top: none; /* the trigger's own bottom border is the seam */
  border-radius: 0 0 var(--r-1) var(--r-1);
  box-shadow: var(--elev-float);
  padding: var(--space-1);   /* the list panel's gutter */
  max-height: 240px;
  /* both axes named: a `visible` axis beside a scrolling one computes to `auto`,
     which would hang a horizontal scrollbar off any option wider than the trigger */
  overflow: hidden auto;
  /* the panel rides up into the seam, the clip's top edge rides down by the
     same amount and holds station on it. The bottom rides WITH the panel, one
     bleed below its own edge, so the shadow is a property of what is still on
     screen. Pinned in page space instead, it lets a 122px panel's shadow paint
     at full strength under a 2px sliver and vanish in one frame. */
  transform: translateY(calc(var(--fold) * -100%));
  clip-path: inset(
    calc(var(--fold) * 100%)
    calc(-1 * var(--menu-bleed))
    calc(-1 * var(--menu-bleed))
    calc(-1 * var(--menu-bleed)));
  opacity: var(--fade);
  visibility: hidden;
  /* the delay carries the panel to the end of the root's fold */
  transition: visibility 0s linear var(--dur-out-near);
}
.select.open > .select-menu { visibility: visible; transition: visibility 0s; }
/* the panel's outline holds through the whole exit: it is hidden before the
   trigger's own outline finishes settling, so the two never disagree on screen */
.select:is(.open, .closing) > .select-menu { border-color: var(--open-edge); }
/* no room below: the panel fuses to the top edge and folds downward into it */
.select-up > .select-menu {
  top: auto;
  bottom: 100%;
  border-top: 1px solid var(--line);
  border-bottom: none;
  border-radius: var(--r-1) var(--r-1) 0 0;
  transform: translateY(calc(var(--fold) * 100%));
  clip-path: inset(
    calc(var(--fold) * -100% - var(--menu-bleed))
    calc(-1 * var(--menu-bleed))
    calc(var(--fold) * 100%)
    calc(-1 * var(--menu-bleed)));
}

/* 8 plus the panel's own 4px gutter: the text still lines up with the trigger's */
.select-menu .menu-item { padding-inline: 8px; }
/* The trigger keeps DOM focus while the panel is open, so the arrow-key row
   carries its own mark. It is the row's HOVER costume, not a 2px accent edge:
   `.focus` lands on the selected row at open time, so the edge stacked against
   the panel's own accent border AND against that row's fill, which is the band
   plus capsule her 2026-09-02 ruling retired. The selected row keeps the fill as
   its one mark, so the two states never paint on one row. */
.select-menu .menu-item.focus:not([aria-selected="true"]) {
  background: var(--surface-2);
  --tint-ground: transparent;
  color: var(--text);
}

.input-wrap { position: relative; }
.input-wrap.has-icon .input { padding-left: 32px; }
.input-icon {
  position: absolute;
  /* a typed field's wrap is a positioned sibling later in the tree, and
     positioned siblings paint in DOM order: without this the field's own
     fill buries the glyph */
  z-index: 1;
  left: 10px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-4);
  pointer-events: none;
  display: flex;
  align-items: center;
}
.input-icon svg { width: 14px; height: 14px; }
.input-suffix {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  font: 400 var(--t-1)/1 var(--font-mono);
  color: var(--text-4);
  pointer-events: none;
}

/* Checkbox / radio (using native inputs) */
.checkbox-label, .radio-label {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--t-3);
  color: var(--text-2);
  cursor: pointer;
  user-select: none;
}
.checkbox-label:hover, .radio-label:hover { color: var(--text); }
/* a disabled box takes its row with it, as `.input-group` takes its addons: the
   label kept the pointer, its hover and its live colour, so a dead row read as
   live. --text-disabled: 4.50:1 on --bg and 3.89 on --surface-1 dark, 3.76 and
   4.23 light, over the 3:1 floor for a control that cannot act. */
.checkbox-label:has(input:disabled),
.radio-label:has(input:disabled) {
  color: var(--text-disabled);
  cursor: not-allowed;
  pointer-events: none;
}

.checkbox-label input[type="checkbox"],
.radio-label input[type="radio"] {
  width: 16px;
  height: 16px;
  appearance: none;
  background: var(--well);
  --tint-ground: transparent;
  border: 1px solid var(--line);
  border-radius: calc(var(--r-1) * 0.5);
  cursor: pointer;
  flex-shrink: 0;
  position: relative;
  transition: border-color var(--dur-tint) var(--ease), background var(--dur-tint) var(--ease);
}
.radio-label input[type="radio"] { border-radius: 50%; }

/* Partial selection: a select-all can express "some of these", and it wears the
   same filled box as a checked one. */
.checkbox-label input[type="checkbox"]:checked,
.radio-label input[type="radio"]:checked,
.checkbox-label input[type="checkbox"]:indeterminate {
  background: var(--accent);
  --tint-ground: transparent;
  border-color: var(--accent);
}
/* the marks live on a pseudo that always exists: the check and the bar
   scale and fade in on the state change, and a pseudo created by the
   state itself would have no previous frame to transition from. A check
   and a dot are ONE mark; only the shape below tells them apart. */
.checkbox-label input[type="checkbox"]::after,
.radio-label input[type="radio"]::after {
  content: '';
  position: absolute;
  inset: 3px;
  background: var(--on-accent);
  --tint-ground: transparent;
  opacity: 0;
  transform: scale(0.4);
  transition: transform var(--dur-near) var(--ease-out), opacity var(--dur-near) var(--ease-out);
}
.checkbox-label input[type="checkbox"]::after { clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); }
.radio-label input[type="radio"]::after { border-radius: 50%; }
.checkbox-label input[type="checkbox"]:checked::after,
.radio-label input[type="radio"]:checked::after { opacity: 1; transform: scale(1); }
/* an indeterminate box is never :checked, so this owns the bar's visible state.
   It ties the merged :checked rule above on specificity and must stay after it,
   since a box can be scripted :checked and indeterminate at once. */
.checkbox-label input[type="checkbox"]:indeterminate::after {
  inset: auto 3px;
  top: 50%;
  height: 2px;
  clip-path: none;
  opacity: 1;
  transform: translateY(-50%) scaleX(1);
}

/* Toggle (CSS-only, .on toggles state) */
.toggle-wrap {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  cursor: pointer;
  user-select: none;
}
.toggle {
  width: 38px;
  height: 22px;
  background: var(--surface-3);
  --tint-ground: transparent;
  border-radius: var(--r-pill);
  position: relative;
  transition: background var(--dur-tint) var(--ease);
  flex-shrink: 0;
  /* Safe on a <button>: a div toggle is not focusable and misses every
     keyboard user. Ship it as a button with aria-pressed. */
  border: none;
  padding: 0;
  appearance: none;
  cursor: pointer;
}
.toggle::after {
  content: '';
  position: absolute;
  top: 3px;
  left: 3px;
  width: 16px;
  height: 16px;
  background: var(--text);
  --tint-ground: transparent;
  border-radius: 50%;
  transition: transform var(--dur-near) var(--ease);
}
.toggle.on { background: var(--accent); --tint-ground: transparent; }
.toggle.on::after { transform: translateX(16px); }
/* A switch is a graphic, so the disabled costume moves its FILLS rather than a
   text colour: the off track drops --surface-3 to --surface-2 and the on track
   --accent to --accent-quiet, each one step quieter against what it sits on. The
   knob keeps --text-4 rather than --text-disabled, because it is the mark that
   carries the state and has to clear 3:1 on both tracks: 4.00 and 3.47 dark, 4.65
   and 4.75 light. --text-disabled measures 2.86 on the dark on-track and fails.
   .on stays legible because a disabled control still reports where it stands. */
.toggle:disabled { background: var(--surface-2); --tint-ground: transparent; cursor: not-allowed; pointer-events: none; }
.toggle:disabled.on { background: var(--accent-quiet); }
.toggle:disabled::after { background: var(--text-4); --tint-ground: transparent; }
.toggle-label { font-size: var(--t-3); color: var(--text-2); }

/* ---- Disclosure (native details/summary) ----
   Zero JS, keyboard and AT support for free; the chevron carries the
   open state. Author it open in the HTML; collapse is a viewer choice. */
.disclosure summary {
  list-style: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2);
  border-radius: var(--r-1);
  font: 500 var(--t-3)/1.2 var(--font-sans);
  color: var(--text-2);
  user-select: none;
}
.disclosure summary::-webkit-details-marker { display: none; }
.disclosure summary svg {
  width: 14px;
  height: 14px;
  transition: transform var(--dur-tint) var(--ease);
}
.disclosure[open] > summary { color: var(--text); }
.disclosure[open] > summary svg { transform: rotate(90deg); }
/* The body lines up with the LABEL, never with the chevron: the summary's own
   inline padding, plus the 14px glyph and the gap after it. At the summary's
   padding alone the copy hung 22px left of the title it belongs to. */
.disclosure-body {
  padding: var(--space-2) var(--space-2) var(--space-3) calc(var(--space-2) * 2 + 14px);
}

/* A class documented on a <div> that is actually interactive gets built as a
   <button> by anyone who cares about the keyboard, and a bare button inherits
   the UA border, font and centring. Reset them once, here, rather than in every
   consumer. */
button.card-interactive,
button.menu-item,
button.sidebar-search,
button.setting-row {
  border: none;
  background: none;
  font: inherit;
  color: inherit;
  text-align: inherit;
  width: 100%;
}

/* ---- Card ----
   Rises toward the light: surface-1 over the page, a crisp drawn line round
   its box, and no shadow at rest. The line is --card-line, a 1px box-shadow
   drawn outside the box so the content box never shrinks. */
/* One raised surface. `.card-tight` and `.card-roomy` reach `.card` alone, so a
   stat card takes the default padding and nothing else changes. */
.card, .stat-card {
  position: relative;
  background: var(--surface-1);
  --tint-ground: transparent;
  box-shadow: 0 0 0 1px var(--card-line);
  padding: var(--space-5);
}
/* the KPI tile takes the fourth, squircle step; every other card keeps --r-2 */
.card { border-radius: var(--r-2); }
.stat-card { border-radius: var(--r-4); }
.card-interactive {
  cursor: pointer;
  text-decoration: none;
  transition:
    transform var(--dur-tint) var(--ease),
    box-shadow var(--dur-tint) var(--ease);
}
/* the lift deepens the contact shadow; while the card is raised, the line
   gives way to it */
.card-interactive:hover {
  transform: translateY(-2px);
  box-shadow: var(--contact-2);
}
/* A card header and a modal header are one row: a space-between line under a
   hairline. Only the padding tells them apart, so only the padding is written
   twice. `.tier-head`, `.daterange-head` and `.meter-head` look like members and
   are not: none of them carries the hairline. */
.card-header,
.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  border-bottom: 1px solid var(--line-soft);
}
.card-header {
  padding-bottom: var(--space-3);
  margin-bottom: var(--space-3);
}
/* A status tag anchors to the card's TOP right, never to the middle of the
   header row. Her call 2026-09-02, and it settles `.stat-card` and the timeline
   entry the same way for whenever either grows one. A header pairing a tag with
   a title PLUS a subtitle centres the tag against both lines, which is what put
   it mid-card.
   `baseline` rather than `flex-start` plus a nudge: the tag has to sit on the
   TITLE's first line, and only the baseline knows where that is. A nudge
   computed from the default type misses every instance that overrides it (2.6px
   off at --t-4, which `examples/dashboard.html` already writes) and a tag one
   level down misses the rule entirely (10.96px, back to mid-header). Baseline
   holds within 2.5px at every size and depth measured, with no number to keep in
   step with the type scale. A control sharing the row rides the same line. */
.card-header:has(.tag) { align-items: baseline; }
.card-title {
  font: 500 var(--t-5)/1.3 var(--font-sans);
  color: var(--text);
}
.card-subtitle {
  font-size: var(--t-2);
  color: var(--text-3);
  margin-top: 2px;
}
/* A header that IS the whole card leaves a stray underline hanging under
   the content. Drop it. */
.card > .card-header:last-child {
  padding-bottom: 0;
  margin-bottom: 0;
  border-bottom: none;
}

/* ---- Tag / Pill ----
   Soft tint background plus a text colour mixed toward --text so the pair
   clears 4.5:1 on cards and on the page in both themes. Dark: 80/20 mix,
   worst pair 4.70:1 on surface-1. Light: 70/30, worst pair 4.78:1 on --bg. */
.tag {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  gap: 5px;
  font: 500 var(--t-1)/1 var(--font-sans);
  padding: 3px 8px;
  border-radius: var(--r-pill);
  border: 1px solid transparent;
  white-space: nowrap;
}
.tag-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; background: currentColor; }

/* The neutral chip fill, worn by the plain tag and by the mono data chip. It is
   a selector list rather than a "write .tag-default too" instruction, because
   `.tag-data` is documented as a standalone variant of `.tag`. `.avatar-more`
   spells the same three and cannot join: it would land before `.avatar`'s own
   accent fill at equal specificity and repaint the overflow chip. */
.tag-default,
.tag-data {
  background: var(--surface-2);
  --tint-ground: transparent;
  color: var(--text-3);
  border-color: var(--line);
}
.tag-accent {
  background: var(--accent-quiet);
  color: var(--accent-text);
}
.tag-success {
  background: color-mix(in srgb, var(--success) 14%, var(--tint-ground));
  color: color-mix(in srgb, var(--success) 80%, var(--text) 20%);
}
.tag-warning {
  background: color-mix(in srgb, var(--warning) 14%, var(--tint-ground));
  color: color-mix(in srgb, var(--warning) 80%, var(--text) 20%);
}
.tag-danger {
  background: color-mix(in srgb, var(--danger) 14%, var(--tint-ground));
  color: color-mix(in srgb, var(--danger) 80%, var(--text) 20%);
}
.tag-info {
  background: color-mix(in srgb, var(--info) 14%, var(--tint-ground));
  color: color-mix(in srgb, var(--info) 80%, var(--text) 20%);
}
[data-theme="light"] .tag-success { color: color-mix(in srgb, var(--success) 70%, var(--text) 30%); }
[data-theme="light"] .tag-warning { color: color-mix(in srgb, var(--warning) 70%, var(--text) 30%); }
[data-theme="light"] .tag-danger  { color: color-mix(in srgb, var(--danger)  70%, var(--text) 30%); }
[data-theme="light"] .tag-info    { color: color-mix(in srgb, var(--accent)  70%, var(--text) 30%); }

/* ---- Table ---- */
.table-wrap {
  background: var(--surface-1);
  --tint-ground: transparent;
  border-radius: var(--r-2);
  box-shadow: var(--elev-1);
  /* A too-wide table scrolls in its own box; clipping it amputates columns
     silently. Seven projects fixed this same defect independently. */
  overflow-x: auto;
  overflow-y: hidden;
  /* The global focus ring floats 3px outside the cell box, so a landing that
     stops a cell flush with the scroller edge amputates the first column's
     arc into the clip. scroll-padding gives scrollIntoView landings room to
     stop short of the edge (measured: 0px without, 8px with, chrome 152).
     Tab focus does not use it: chrome leaves a partially visible cell
     unscrolled, so a control flush with a clipped edge wants an inset ring
     instead. */
  scroll-padding-inline: 8px;
}
.table-wrap table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--t-2);
}
.table-wrap thead tr {
  border-bottom: 1px solid var(--line-soft);
  background: var(--well);
  --tint-ground: transparent;
}
[data-theme="light"] .table-wrap thead tr { background: var(--surface-2); --tint-ground: transparent; }
/* THE tracked-caps eyebrow, and the three places it is sanctioned: a table
   header, a navigation-list group label, a weekday name. Not a merge into
   `tokens.css`'s `.label`, which declares 11px/1.4 where all three declare /1 and
   sit in fixed-height boxes a 1.4 line box would re-baseline. */
.table-wrap thead th,
.nav-group-label,
.daterange-dow {
  font: 500 var(--t-1)/1 var(--font-sans);
  letter-spacing: var(--caps);
  text-transform: uppercase;
}
.table-wrap thead th {
  color: var(--text-3);
  padding: 10px var(--space-4);
  text-align: left;
  white-space: nowrap;
}
.table-wrap thead th.num-col { text-align: right; }
/* A HEADER LABEL CENTRED BY THE CELL IS NOT CENTRED. Chrome centres a cell's LINE
   BOX, and all-caps ink rides high in it by half the descender space the caps never
   use: measured 0.88px on the sortable header and 0.75 on the sticky table, against
   0.12 for the same caps flex-centred in `.nav-group-label`, with the select-all
   checkbox beside them dead on the row's centre line. Nothing on the cell corrects
   it - `position: relative` plus `top` is ignored on a table cell, an asymmetric
   padding is absorbed when the cell's intrinsic padding is recomputed, and
   line-height only flips which way the row height rounds. So the label takes a span
   of its own, which `ui.js` writes and markup must not, and that span carries the
   half pixel below. It needs no BOX: an earlier version gave it a 14px inline-flex
   matching the sort glyph, on the reasoning that both would then align on their
   middles; removing that changed no alignment and gave every plain header row back
   the 3px it had grown. What holds the sortable and plain labels together is
   `vertical-align` on the button, one screen down. A DESCENDANT selector, so the
   half pixel reaches the label in both places: moving only the plain one would
   split the row again. With all five labels in the sortable row on one range top
   and one ink top, what is left is DEVICE-PIXEL PHASE and no stylesheet reaches
   it: a table's reading swings about a pixel with its fractional page position,
   measured -0.12 and +0.62 on one dashboard table 13.4px apart. So the nudge
   CENTRES that swing rather than fitting one table: at 0.5px the nine tables on
   disk read -0.25 to +0.62, at 0.25px -0.50 to +0.12, at the 0.5px budget's
   edge and never past it. */
.table-wrap thead th .th-label { position: relative; top: 0.25px; }
.table-wrap tbody tr {
  border-bottom: 1px solid var(--line-soft);
  /* the transition lives with the selection rules in the table-patterns
     block, which restate this selector at equal specificity later */
}
.table-wrap tbody tr:last-child { border-bottom: none; }
.table-wrap tbody tr:hover { background: var(--surface-2); --tint-ground: transparent; }
.table-wrap tbody td {
  padding: 10px var(--space-4);
  color: var(--text-2);
  vertical-align: middle;
  /* break-word, not `anywhere`. Both break a string with no break opportunity in
     it, which is the job here; only `anywhere` also collapses the cell's
     min-content contribution to a single character, so auto table layout reads
     every column as able to be 1ch wide, starves the narrow ones and then breaks
     their ordinary words mid-token (measured `platform` as `platfor` / `m` and
     `ap-southeast-1` as `ap-` / `southea` / `st-1` beside a 320px sibling).
     The page is still protected by `.table-wrap`'s own overflow-x. */
  overflow-wrap: break-word;
}
.table-wrap tbody td.num-col {
  text-align: right;
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
  color: var(--text);
  font-family: var(--font-mono);
  font-size: var(--t-2);
}
.table-name { color: var(--text); font-weight: 500; }
/* A nowrap header is what makes a table wider than a phone in the first place. */
@media (max-width: 720px) {
  .table-wrap thead th { white-space: normal; }
}

/* ---- Code / kbd ---- */
code {
  font: 400 var(--t-2)/1 var(--font-mono);
  background: var(--well);
  --tint-ground: transparent;
  border: 1px solid var(--line-soft);
  padding: 2px 6px;
  border-radius: var(--r-1);
  color: var(--text-3);
  /* a hyphen is a break opportunity, so `--text-4` wrapped after its dashes and
     painted two chips: one reading `--`. A chip is one token or it is not one */
  white-space: nowrap;
}
kbd {
  font: 500 var(--t-1)/1 var(--font-mono);
  background: var(--surface-1);
  --tint-ground: transparent;
  border: 1px solid var(--line);
  border-bottom-width: 2px;
  border-radius: var(--r-1);
  padding: 2px 6px;
  color: var(--text-3);
}

/* Block code: three projects hand-rolled this per page before it existed. */
pre {
  font: 400 var(--t-2)/1.5 var(--font-mono);
  background: var(--well);
  --tint-ground: transparent;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-1);
  padding: var(--space-3) var(--space-4);
  overflow-x: auto;
  color: var(--text-2);
}
pre code {
  background: none;
  border: none;
  padding: 0;
  font-size: inherit;
  color: inherit;
  white-space: inherit;
}
/* Label strip above a pre: file name, language, exit status. The last slot
   pins to the right edge, which is where the language (or exit status) reads.
   Mono, as typed: the tracked-caps signature stays on its sanctioned places. */
.code-head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-4);
  background: var(--well);
  --tint-ground: transparent;
  border: 1px solid var(--line-soft);
  border-bottom: none;
  border-radius: var(--r-1) var(--r-1) 0 0;
  font: 500 var(--t-1)/1 var(--font-mono);
  color: var(--text-4);
}
.code-head > :last-child { margin-left: auto; }
/* The copy affordance floats over the code's top-right corner inside a
   `.code-body` wrapper, hidden until the block is hovered or keyboard focus
   reaches it. It is a bare 24px icon, not a button from the ladder: no
   border, no fill at rest, below every control size. The hover backdrop
   keeps it legible over code text; the check pop rides the state swap.
   text-3 measures 7.05:1 on the well dark and 4.82:1 light. */
.code-body { position: relative; }
.code-copy {
  position: absolute;
  top: var(--space-2);
  right: var(--space-2);
  z-index: 1;
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: none;
  border-radius: var(--r-pill);
  background: none;
  color: var(--text-3);
  cursor: pointer;
  opacity: 0;
  transition: opacity var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease),
              background var(--dur-tint) var(--ease);
}
:hover > .code-copy, .code-copy:focus-visible { opacity: 1; }
.code-copy:hover, .code-copy:focus-visible {
  color: var(--text);
  background: color-mix(in srgb, var(--surface-2) 80%, var(--tint-ground));
}
.code-copy svg { width: 14px; height: 14px; }
.code-copy .icon-check { animation: check-pop var(--dur-near) var(--ease-out); }
@keyframes check-pop {
  from { transform: scale(0.6); opacity: 0; }
}
@media (hover: none) { .code-copy { opacity: 1; } }
@media (pointer: coarse) { .code-copy { width: 44px; height: 44px; } }
.code-head + pre,
.code-head + .code-body > pre {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}
/* Terminal output: capped, scrollable, wraps instead of widening the page. */
.log-view {
  max-height: 30rem;
  overflow: auto;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  resize: vertical;
}

/* ---- Diff marks ----
   The tint carries the semantics; the text keeps its own reading contrast. */
.diff-add, .diff-remove { border-radius: 2px; padding: 0 1px; }
.diff-add { background: color-mix(in srgb, var(--success) 22%, var(--tint-ground)); }
.diff-remove { background: color-mix(in srgb, var(--danger) 22%, var(--tint-ground)); }

/* ---- Stat / KPI ---- */
.stat-label {
  font: 500 var(--t-1)/1 var(--font-sans);
  color: var(--text-3);
  margin-bottom: var(--space-3);
}
/* THE mono numeric readout: a KPI, a ring's centre, a claim, a price. Only the
   size changes. LONGHANDS, never the `font` shorthand: a shorthand here at
   (0,1,0) would erase `.claim-value`'s own breakpoint size and `.ring-sm`'s,
   which are the two sizes a consumer can actually see move.
   --text measures 11.70 / 12.98 / 10.12 dark and 11.98 / 10.95 / 13.47 light on
   the three grounds these sit on. */
.stat-value,
.ring-value,
.claim-value,
.tier-price {
  color: var(--text);
  font-family: var(--font-mono);
  font-weight: 600;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
}
.stat-value,
.ring-value,
.claim-value { overflow-wrap: anywhere; }
.stat-value  { font-size: var(--t-7); letter-spacing: -0.03em; }
.ring-value  { font-size: var(--t-6); }
@media (max-width: 768px) { .stat-value { font-size: var(--t-6); } }
.stat-delta {
  font: 400 var(--t-2)/1 var(--font-sans);
  margin-top: var(--space-2);
  color: var(--text-3);
}
.stat-delta.up   { color: var(--success); }
.stat-delta.down { color: var(--danger); }

/* ---- Annotation ----
   The editorial accent's one job, and its only one. Orange never colours a
   value, a control or a status: it marks the single thing on a screen that
   needs saying, a threshold, a rollout, a cutover. Once per screen.
   The label is the eyebrow treatment in --accent-2, so the tracked caps stay
   inside their allowlist rather than gaining a fourth place. Measured:
   --accent-2 6.09:1 on --bg and 5.27:1 on --surface-1 dark, 5.16 and 5.80
   light. The rule is a graphic, over the 3:1 floor on all four. */
.annot {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}
.annot > .label { color: var(--accent-2); white-space: nowrap; }
.annot-rule {
  flex: 1 1 auto;
  height: 2px;
  border-radius: var(--r-pill);
  background: var(--accent-2);
  --tint-ground: transparent;
}
/* Vertical form: marks a row in a list or a band in a chart. The bar and its
   gap live in the container's own gutter, pulled back by exactly their width,
   so the marked line's first glyph lands where every plain line's does: with
   the padding alone the two measured 14px apart. */
.annot-mark {
  border-inline-start: 2px solid var(--accent-2);
  padding-inline-start: var(--space-3);
  margin-inline-start: calc(-2px - var(--space-3));
}

/* ---- Sparkline ----
   preserveAspectRatio="none" stretches the viewBox to the card's width,
   which would thin and fatten a declared stroke with the card (the
   scaled-stroke trap, cursor.md). non-scaling-stroke pins the drawn
   width at 1.5 css px whatever the stretch. Line contrast on the card,
   WCAG 1.4.11's 3:1 graphics floor: series-1 5.21:1 dark, 5.81:1 light
   on --surface-1, measured. */
.stat-spark {
  --spark: var(--chart-series-1);
  --series: var(--spark);   /* the area and end point ui.js builds read --series */
  height: 28px;
  margin-top: var(--space-3);
}
.stat-spark svg { width: 100%; height: 100%; display: block; }
.stat-spark polyline {
  fill: none;
  stroke: var(--spark);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* ---- Chart furniture ----
   Series colours live in tokens (--chart-series-*), four slots, order
   chosen for protan adjacency. A canvas chart needs a bounded box; an
   empty one overlays its own state instead of drawing axes into nothing. */
.chart-box { position: relative; }
.chart-box canvas { display: block; }
.chart-scroll { overflow-x: auto; }
.chart-scroll canvas { min-width: 320px; }
.chart-empty {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: var(--t-2);
  color: var(--text-4);
}
.legend { display: flex; flex-wrap: wrap; gap: var(--space-3); }
/* A chart legend row and a status legend row are one object; only the MARKS
   inside them differ, which the status-legend block explains at its own site.
   --text-3 is 5.49 on a card and 6.35 on the page dark, 5.92 / 5.27 light. */
.legend-item,
.status-legend-item {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--t-2);
  color: var(--text-3);
}
.legend-line { width: 12px; height: 3px; border-radius: var(--r-pill); }

/* ---- Toast ----
   One sanctioned place for the float shadow. Enter: fade plus 8px rise,
   decelerating. Exit: runs at 0.6x. The icon carries the semantic colour;
   no left stripe. */
.toast-stack {
  position: fixed;
  bottom: var(--space-6);
  right: var(--space-6);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  z-index: 1000;
  pointer-events: none;
  width: min(340px, calc(100vw - 2 * var(--space-4)));
}
.toast {
  background: var(--surface-1);
  --tint-ground: transparent;
  border-radius: var(--r-2);
  box-shadow: var(--elev-float);
  padding: var(--space-4);
  display: flex;
  align-items: center;
  gap: var(--space-3);
  pointer-events: all;
  animation: toast-in var(--dur-near) var(--ease-out) both;
}
.toast.removing { animation: toast-out var(--dur-out-near) var(--ease) both; }
@keyframes toast-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}
@keyframes toast-out {
  to { opacity: 0; transform: translateY(4px); }
}
/* ONE icon slot, worn by every tinted panel in the system. */
.toast-icon,
.callout-icon,
.form-summary-icon,
.banner-icon { flex-shrink: 0; }
/* An icon dropped in without its own width lays out at 0, so the slot states
   it. `display: block` is what centres it: an inline svg sits on the text
   baseline, so the slot's line box runs 22.69px around a 16px glyph and the
   descender space rides the glyph 3.34px above the slot's own centre. Measured
   on every panel but the banner, which carried this one declaration alone. */
.toast-icon > svg,
.callout-icon > svg,
.form-summary-icon > svg,
.banner-icon > svg { display: block; width: 16px; height: 16px; }
.toast-body { flex: 1; min-width: 0; }
.toast-msg   { font-size: var(--t-2); color: var(--text-3); margin-top: 3px; }

/* ---- Callout / alert ----
   A 6 percent tint over surface-1, a lit top edge, and the icon in the
   semantic colour. Text on the tint measures 6.4:1 and up in both themes.
   THE tinted panel: the banner and the validation summary are the same
   material and take this shell. The tints themselves are declared once, in the
   banner block, because they have to land after `.banner`'s own untinted
   default and both are (0,1,0). */
.form-summary,
.callout,
.banner {
  display: flex;
  /* the icon centres on the BODY, her call 2026-09-02: a 1px nudge onto the
     first line put a two-line body's icon high by half the second line */
  align-items: center;
  gap: var(--space-3);
  border-radius: var(--r-2);
  box-shadow: var(--lit-1);
}
.form-summary,
.callout { padding: 12px var(--space-4); }
.callout,
.banner { background: var(--surface-1); --tint-ground: transparent; }
/* The icon carries the semantic, so the variant colours it. Leaving that to an
   inline style is how a callout ships with a --text icon and reads as neutral. */
.callout-info    > .callout-icon { color: var(--info); }
.callout-success > .callout-icon { color: var(--success); }
.callout-warning > .callout-icon { color: var(--warning); }
.callout-danger  > .callout-icon { color: var(--danger); }
/* THE panel title line, worn by five panels. --text measures 8.86:1 dark and
   12.23:1 light at worst on the four tints, 11.70 / 11.98 on --bg, and
   10.12 / 13.47 on --surface-1. The timeline entry and the banner carry a
   slightly looser line, which is the only thing separating the five. */
.form-summary-title,
.toast-title,
.callout-title,
.timeline-title,
.banner-title { font: 500 var(--t-3)/1.3 var(--font-sans); color: var(--text); }
.timeline-title,
.banner-title { line-height: 1.35; }
.callout-title { margin-bottom: 2px; }
.callout-body  { font-size: var(--t-2); color: var(--text-2); overflow-wrap: anywhere; }

/* ---- Popover / menu ----
   The entrance scales from the edge nearest its trigger: the placement
   script sets transform-origin per side, never the centre. */
.popover {
  background: var(--surface-1);
  --tint-ground: transparent;
  border-radius: var(--r-2);
  box-shadow: var(--elev-float);
  overflow: hidden;
  animation: popover-in var(--dur-near) var(--ease-out) both;
}
@keyframes popover-in {
  from { opacity: 0; transform: translateY(-4px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}
/* The exit belongs to the shell, for the reason the anchoring does: four
   consumers each declared this line, and a bare `.popover` had an entrance and
   no exit, so `initPopovers` read its 180ms off the ENTRANCE and pulled the node
   with nothing playing. A costume with its own exit (`.tooltip`, `.palette`)
   outranks this at (0,3,0). */
.popover.closing,
/* the one consumer that cannot carry `.closing` itself: its controller marks the
   WRAPPER, so the panel is named from outside instead of restating the exit */
.daterange.closing > .daterange-panel { animation: popover-out var(--dur-out-near) var(--ease) both; }
@keyframes popover-out { to { opacity: 0; transform: translateY(-4px) scale(0.98); } }
/* A menu hanging under the control that opens it. `.popover` alone is a bare
   surface with no position, so a consumer writing it by itself gets a full-width
   box in flow; the filter menu and the split button each re-declared this. The
   wrapper carries `position: relative`, and each consumer picks only its edge. */
.popover-menu {
  position: absolute;
  top: calc(100% + var(--space-1));
  inset-inline-start: 0;
  z-index: 60;
  min-width: 180px;
}
.menu-item {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  /* 10 and not 14: the panel's own 4px gutter supplies the rest, so the text
     column sits where it always did, 14px in from the panel edge. */
  padding: 8px 10px;
  /* THE row radius, in every state. A radius that arrives with a state is a
     corner that animates, and it left a hovered row's square band meeting a
     selected row's rounded one; a state rule declaring one is that defect
     restated. Her call 2026-09-04. */
  border-radius: var(--r-1);
  font-size: var(--t-3);
  color: var(--text-2);
  cursor: pointer;
  transition: background var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease);
  white-space: nowrap;
  user-select: none;
}
.menu-item > svg { width: 14px; height: 14px; flex-shrink: 0; }
/* THE neutral hover, and every borderless control and list row in the system
   takes it from here. --text on --surface-2 measures 8.56:1 dark, 11.28:1 light.
   It has to sit at or before this point, not with the icon buttons further down:
   `.menu-item.active` and `.input-step:disabled` each tie it at (0,2,0) and have
   to win by coming after. `.menu-item.danger` ties it too, inertly: its own
   `:hover` at (0,3,0) sets the hovered label. `.disclosure[open] > summary` ties
   it too, at (0,2,1), inertly, since both spell --text. The remove control is
   not a member: removing is destructive, and its hover is its own, declared
   with `.close` in the one-remove-control section. */
.copy-chip-btn:hover,
.select-clear:hover,
.row-toggle:hover,
.disclosure summary:hover,
.input-step:hover,
.menu-item:hover { background: var(--surface-2); --tint-ground: transparent; color: var(--text); }
/* The chosen row, whether a menu marks it with `.active` or a select marks it
   with aria. It has to sit AFTER `:hover`, and the ORDER IS ONLY LOAD-BEARING
   FOR THE FIRST SELECTOR: `.menu-item.active` ties the hover at (0,2,0) and
   would repaint grey under the pointer from any earlier position, while the
   select's own selector measures (0,3,0) and wins from anywhere. */
.menu-item.active,
.select-menu .menu-item[aria-selected="true"] { color: var(--accent-text); background: var(--accent-quiet); }
.menu-item.danger { color: var(--danger); }
.menu-item.danger:hover { background: color-mix(in srgb, var(--danger) 10%, var(--tint-ground)); color: var(--danger-text); }
/* The arrow-key row carries the hover costume, the same mark the select's
   listbox takes for its focused option. It has to sit after `.active`: a
   focused active row keeps its accent fill as its one mark. The danger row
   keeps its danger hover, never the neutral band. */
.popover-menu .menu-item.focus:not(.active) {
  background: var(--surface-2);
  --tint-ground: transparent;
  color: var(--text);
}
.popover-menu .menu-item.danger.focus:not(.active) {
  background: color-mix(in srgb, var(--danger) 10%, var(--tint-ground));
  color: var(--danger-text);
}
/* 1px and not 3: the lane below contributes the other 2 on each side, so the
   painted gap around a divider stays the 3px it measured before the lane.
   `flex-shrink: 0` because the lane makes its panel a flex column and the
   select's is height-bounded: a row is floored by its own min-content, an empty
   1px rule is floored by nothing, and it measured 0px tall with no hairline. */
.menu-divider { height: 1px; flex-shrink: 0; background: var(--line-soft); --tint-ground: transparent; margin: 1px 0; }

/* ---- The list panel's one gutter ----
   A row in a list panel sits in a 4px inset with --r-1 corners in every state
   and a 2px lane to the next row, so no two bands touch and no band reaches the
   panel's own corner. Her call 2026-09-04.

   4px is not a taste: on the --r-2 popover a row's corner is concentric with
   the panel's, since --r-2 - --space-1 is exactly --r-1. A clipped rounded
   panel bites r - sqrt(r^2 - (r - gap)^2) out of anything reaching its corner:
   the whole 14 at no gap, still 4.20 at 4px if the row's own corner were
   square. It is the CONCENTRICITY that takes the bite to nothing, not the
   inset alone. Both panels clip at their PADDING box, one glass border inside
   the radius they declare, so the identity lands a pixel off rather than dead
   on: 13 against the row's 10 at a 4px inset here, 9 against 10 in the select,
   whose panel rounds at --r-1. Either way the row's own corner leaves the clip
   nothing to take.

   THE GUTTER IS KEYED ON WHAT THE PANEL HOLDS, not on `.popover-menu`: one page
   writes a bare `.popover` around its rows, and a contract a consumer can miss
   by one class is the contract that ships the defect back. `.select-menu` is
   not a `.popover`, and the palette's rows sit a level down, so those two state
   their own; the lane is shared, because the lane is one number.

   The lane is a gap and not a margin because `.daterange-presets` turns into a
   wrapping row under 560px, where a block margin staggers the line instead of
   separating it. */
.popover:has(> .menu-item) { padding: var(--space-1); }
.popover:has(> .menu-item),
.select-menu,
.palette-group,
.nav-group,
.tree,
.tree-group,
.tree li {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* ---- Tooltip ----
   The popover shell with a placement costume: surface-1 plus the float
   shadow, entrance decelerating out of the trigger's side, exit at 0.6x.
   Placement is a one-shot layout read at open time (inline left, and the
   .below flip class), never an animated property; the move itself stays
   on transform and opacity. The title attribute retires from .btn-icon:
   it never opens on touch, cannot be styled anywhere, and its OS chrome
   fights the ring cursor. */
.tooltip-wrap { position: relative; display: inline-flex; }

.tooltip {
  position: absolute;
  bottom: calc(100% + var(--space-2));   /* default: above the trigger */
  left: 0;
  z-index: 70;
  padding: 5px 10px;
  /* max-content sizes to the text; shrink-to-fit against a 36px wrapper is
     how a two-word tip became a 70px column */
  width: max-content;
  max-width: 240px;
  font: 400 var(--t-2)/1.4 var(--font-sans);
  /* the tip is glass: 4.67:1 dark and 6.68:1 light against the worst backdrop
     the page can put behind it, 7.30 / 8.36 where the material is opaque */
  color: var(--text-2);
}
.tooltip.below { bottom: auto; top: calc(100% + var(--space-2)); }

/* the entrance arrives from the trigger: rising for the default above
   placement, settling for .below, the same move the popover ships */
.popover.tooltip       { animation: tooltip-in-top var(--dur-near) var(--ease-out) both; }
.popover.tooltip.below { animation-name: tooltip-in-below; }
.popover.tooltip.closing       { animation: tooltip-out-top var(--dur-out-near) var(--ease) both; }
/* the below variant lifts as it leaves, the same move the palette exits on, so
   it takes that keyframe rather than a fourth copy of it */
.popover.tooltip.below.closing { animation-name: lift-out; }
@keyframes tooltip-in-top    { from { opacity: 0; transform: translateY(4px)  scale(0.98); } }
@keyframes tooltip-in-below  { from { opacity: 0; transform: translateY(-4px) scale(0.98); } }
@keyframes tooltip-out-top   { to   { opacity: 0; transform: translateY(4px)  scale(0.99); } }

/* Reduced motion: fades stay, transforms go. Restated at each rule's own
   specificity; a shorter selector here is outranked and the transform
   comes back. */
@media (prefers-reduced-motion: reduce) {
  .popover.tooltip, .popover.tooltip.below { animation: fade-in var(--dur-near) var(--ease) both; }
  .popover.tooltip.closing, .popover.tooltip.below.closing { animation: fade-out var(--dur-out-near) var(--ease) both; }
}
@keyframes fade-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes fade-out { to   { opacity: 0; } }

/* ---- Command palette ----
   The popover shell dropped over the page from the top centre: a search head,
   results grouped under tracked labels, one active row, a keycap hint per row.
   Every part of it already shipped, so this block is the assembly plus the one
   thing the shell was missing, an exit.

   Rows are `.menu-item`, so the row costume, the hover state and the coarse
   44px floor come from where they already live. The backdrop is
   `.modal-backdrop`, so the tint is `--backdrop` and the fade is the modal's
   own; only the alignment changes, because the palette opens where the eye
   already is rather than at the centre of the page.

   Every rule that could meet a components.css rule at equal specificity is
   written one class longer, so the block drops in at any point in the file. */

.modal-backdrop.palette-backdrop {
  align-items: start;   /* .modal-backdrop centres both axes */
  padding-top: 10vh;
  padding-top: 10dvh;   /* vh lies under mobile browser chrome */
}
/* the modal leaves over --dur-out-far. This panel leaves over --dur-out-near,
   and a backdrop outliving its panel by 48ms reads as a second object. */
.modal-backdrop.palette-backdrop.closing {
  animation: backdrop-out var(--dur-out-near) var(--ease) both;
}

.popover.palette {
  display: flex;
  flex-direction: column;
  width: min(640px, 100%);
  max-height: 60vh;
  max-height: 60dvh;
  /* The scroller stops short of the panel's own corner. A full-bleed row band
     that reaches a clipped --r-2 corner is cut by its curve, and a cut band reads
     as a hairline that stops short of the panel edge: measured with the last row
     active, the band's bottom sat 1.8px above the panel's, where the curve takes
     7.1px off each end. At 8px it takes 1.35. The head supplies the same inset at
     the top, so only this end needs one. */
  padding-block-end: var(--space-2);
  /* the entrance scales from the edge it arrives at, never the centre */
  transform-origin: top center;
}
/* its own exit, outranking the shell's at (0,3,0): the panel is centred, so it
   leaves the way it arrived rather than toward a trigger's edge */
.popover.palette.closing { animation: lift-out var(--dur-out-near) var(--ease) both; }
@keyframes lift-out { to { opacity: 0; transform: translateY(-4px) scale(0.99); } }

/* ---- Search head ----
   Every column down the panel starts 14px in: the head states that gutter
   itself, and the group labels and the rows reach it as their own 10px inside
   the list panel's 4px. */
.palette-search {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) 14px;
  min-height: var(--ctl-lg);
  border-bottom: 1px solid var(--line-soft);
}
.palette-search > svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--text-4); }
.palette-input {
  flex: 1 1 auto;
  min-width: 0;
  background: none;
  border: none;
  padding: 0;
  font: 400 var(--t-4)/1.4 var(--font-sans);
  color: var(--text);   /* on the glass panel: 6.48:1 dark, 10.76:1 light */
}
/* the one placeholder painted straight on a panel rather than a --well, so it
   reads --text-4, the panel's dim text: on the glass panel --text-4 re-floors to
   --glass-dim (4.67:1 dark, 4.72:1 light), and on the opaque --surface-1 it holds
   4.73:1 dark / 5.55:1 light, where the --well-tuned --text-placeholder falls to
   3.89:1 dark. */
.palette-input::placeholder { color: var(--text-4); }
/* The dialog holds focus here for its whole life and nothing else inside it
   takes focus, so a ring around the only focusable element names nothing: the
   caret marks the field, the accent edge marks the keyboard's row. The base
   `:focus-visible` pairs its outline with a 1px --bg separator shadow, so
   dropping the outline alone leaves a stray box drawn around the query. */
.palette-input:focus { outline: none; box-shadow: none; }

/* ---- Results ---- */
.palette-list {
  /* the scroll box is the rows' offset parent, so the controller's reveal math
     reads one coordinate space instead of the backdrop's */
  position: relative;
  /* both axes named: a `visible` axis beside a scrolling one computes to
     `auto`, which hangs a horizontal scrollbar off any row that overflows */
  overflow: hidden auto;
}
/* each group carries its own breathing room, so a group the query filtered out
   contributes none and the list needs no adjacency rule to stay even */
.palette-group { padding: var(--space-2) var(--space-1); }   /* block rhythm, then the list panel's gutter */
/* the sidebar group label's treatment: `.label` carries the caps, the tracking
   and --text-3, which is --glass-dim inside this panel: 4.67:1 dark, 4.72 light */
.palette-group-label { padding: var(--space-1) 10px; }

/* the sidebar's icon treatment: dimmed at rest, full on the active row.
   --icon-dim is 0.7 on an opaque surface and 0.90 on glass, which this panel
   is: at 0.85 a --text-3 icon measures 2.99:1 on the dark panel, under the 3:1
   graphics floor, and 0.90 lands it at 3.15 dark and 3.89 light. */
.palette-row > svg { width: 14px; height: 14px; flex-shrink: 0; opacity: var(--icon-dim); }
.palette-row.active > svg { opacity: 1; }
.palette-name { flex: 1 1 auto; min-width: 0; }   /* a flex child needs this before .truncate can bite */
/* The active row is marked by the shared active-row band and nothing else, and
   `.menu-item.active` supplies --accent-text. On glass that fill is the panel's opaque
   quiet mix, laid down so it cannot composite twice: --accent-text measures 5.95:1
   dark and 4.89 light on it.
   The palette marks its arrow-key row `.active`, so it has one row state and no
   `.focus`: `initSelect` is the only controller that writes that class, over
   `.select [role="option"]`. A `.palette-row.focus` rule can never match. */

.palette-hint { display: flex; gap: var(--space-1); margin-left: auto; flex-shrink: 0; }
/* a --surface-1 keycap on a --surface-1 panel is a border and nothing else, and
   a transparent one falls to 4.03:1 on the active row's fill. One keycap, two
   panels: --text-3 on it reads 4.65:1 dark and 4.96:1 light in the palette,
   --glass-dim 6.17:1 and 4.96:1 on the shortcut sheet's glass. */
.palette-hint kbd,
.palette-search kbd,
.shortcut-keys kbd { background: var(--surface-2); --tint-ground: transparent; }

/* No match: a row that names the fix, built from what was typed. */
.palette-empty {
  padding: var(--space-2) 14px;
  font-size: var(--t-3);
  color: var(--text-3);   /* 5.49:1 dark, 5.92:1 light on --surface-1 */
}

@media (prefers-reduced-motion: reduce) {
  /* restated at the exit rule's own (0,3,0). A bare `.popover` here is
     outranked and the panel keeps its move. */
  .popover.palette.closing { animation: fade-out var(--dur-out-near) var(--ease) both; }
}

/* ---- Avatar ---- */
.avatar {
  /* the box is a token so anything keyed on the disc's size can read it; the
     stacked group's overlap is the one thing that does */
  --av-size: 32px;
  position: relative;   /* the containing block for the presence dot and for .avatar > img */
  width: var(--av-size);
  height: var(--av-size);
  border-radius: var(--r-pill);
  background: var(--accent-quiet);
  border: 1px solid var(--accent-line);
  display: flex;
  align-items: center;
  justify-content: center;
  font: 600 var(--t-2)/1 var(--font-sans);
  color: var(--accent-text);
  flex-shrink: 0;
  user-select: none;
}

/* ---- Progress ---- */
.progress {
  height: 3px;
  background: var(--surface-2);
  --tint-ground: transparent;
  border-radius: var(--r-pill);
  overflow: hidden;
}
.progress-bar {
  height: 100%;
  background: var(--accent);
  --tint-ground: transparent;
  border-radius: var(--r-pill);
  transition: width var(--dur-far) var(--ease);
}

/* ---- Skeleton (no shimmer, by spec) ---- */
.skeleton {
  background: var(--surface-2);
  --tint-ground: transparent;
  border-radius: var(--r-1);
}

/* ---- Empty state ---- */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  padding: var(--space-12) var(--space-6);
  text-align: center;
  color: var(--text-3);
}
.empty-state > svg {
  width: 32px;
  height: 32px;
  color: var(--text-4);
  margin-bottom: var(--space-2);
}
.empty-title { font: 500 var(--t-4)/1.3 var(--font-sans); color: var(--text); }
.empty-state .btn { margin-top: var(--space-3); }

/* ---- Spinner (loading outside buttons) ----
   The ring is a graphic, so it holds the 3:1 floor: --accent is 7.49:1 on
   --bg and 6.48 on --surface-1 dark, 4.09 / 4.60 light. --accent-line was
   2.25 / 1.56 on --bg. */
.spinner {
  display: inline-block;
  width: 14px;
  height: 14px;
  border: 2px solid var(--accent);
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}
/* rests under reduce like the button's ring; the consumers keep their own
   done-state cues beside it (a check glyph, a chevron, a dirty hairline) */
@media (prefers-reduced-motion: reduce) {
  .spinner { animation: none; }
}

/* ---- Modal ----
   Four projects hand-built this against "out of scope". The entrance
   arrives from the front; the exit runs at 0.6x. Esc and backdrop close
   are page JS; the css ships the shape. */
.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 540;
  background: var(--backdrop);
  display: grid;
  place-items: center;
  padding: var(--space-4);
  animation: backdrop-in var(--dur-near) var(--ease) both;
}
.modal {
  background: var(--surface-1);
  --tint-ground: transparent;
  border-radius: var(--r-3);
  box-shadow: var(--elev-float);
  width: min(560px, 100%);
  max-height: 92vh;
  max-height: 92dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  animation: modal-in var(--dur-far) var(--ease-out) both;
}
.modal-head { padding: var(--space-4) var(--space-5); }
.modal-title { font: 600 var(--t-4)/1.3 var(--font-sans); color: var(--text); }
.modal-body { padding: var(--space-5); overflow-y: auto; }
.modal-actions {
  padding: var(--space-4) var(--space-5);
  border-top: 1px solid var(--line-soft);
  display: flex;
  justify-content: flex-end;
  gap: var(--space-2);
}
.modal-backdrop.closing { animation: backdrop-out var(--dur-out-far) var(--ease) both; }
.modal-backdrop.closing .modal { animation: modal-out var(--dur-out-far) var(--ease) both; }
/* The scrim fades on its own background-color, never on opacity. An ancestor at
   opacity < 1 is a BACKDROP ROOT, and a filled forwards animation keeps it one
   after it settles, so a glass panel inside this element blurs nothing at all:
   measured, a wrapper at opacity 1 gives a 32px ramp across a hard edge and the
   same wrapper at 0.99 gives 0px, and the modal shipped a 3-level swing where a
   toast on the same page moved 25. Colours interpolate premultiplied, so the
   alpha fade does not travel through black. */
@keyframes backdrop-in { from { background-color: transparent; } }
@keyframes backdrop-out { to { background-color: transparent; } }
@keyframes modal-in {
  from { opacity: 0; transform: translateY(12px) scale(0.98); }
}
@keyframes modal-out {
  to { opacity: 0; transform: translateY(6px) scale(0.99); }
}

/* ---- Data chip (mono variant of .tag) ----
   Machine / numeric values: dates, token counts, costs, durations, hashes. */
.tag-data {
  font-family: var(--font-mono);
  text-transform: none;
  font-variant-numeric: tabular-nums;
}

/* ---- Key-value / metadata table ----
   Mono readout, no header row; fixed-width label column. */
.info-table { width: 100%; border-collapse: collapse; font: 400 var(--t-2)/1.5 var(--font-mono); }
.info-table td {
  padding: 8px var(--space-3);
  border-bottom: 1px solid var(--line-soft);
  color: var(--text);
  vertical-align: top;
  overflow-wrap: anywhere;
}
.info-table tr:last-child td { border-bottom: none; }
/* Sentence case, not tracked caps. The allowlist is three places and this is
   not one of them, and a key column holds case-sensitive values: a class name
   or an env var uppercased is a different string. */
.info-table td:first-child {
  width: 140px;
  font: 500 var(--t-1)/1.5 var(--font-sans);
  color: var(--text-4);
  white-space: nowrap;
}

/* ---- Range slider ----
   Real elements, no native range: a UA shadow thumb paints the platform pointer over
   the ring whatever cursor the page declares (her tools-page followup: the thumb
   pseudo computed `none` and the pointer still painted), the same family the native
   select and the file input retired for. The thumb rides the fill's right edge, so one
   width drives both. */
.slider {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
  height: 14px;
  cursor: pointer;
  touch-action: none;
}
.slider-track {
  position: relative;
  width: 100%;
  height: 4px;
  background: var(--surface-2);
  --tint-ground: transparent;
  border-radius: var(--r-pill);
}
.slider-fill {
  position: relative;  /* the thumb's containing block: without it the thumb anchors to the track and ignores the fill width */
  height: 100%;
  width: 0;
  background: var(--accent);
  --tint-ground: transparent;
  border-radius: var(--r-pill);
}
.slider-thumb {
  position: absolute;
  right: -7px;
  top: 50%;
  transform: translateY(-50%);
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--accent);
  --tint-ground: transparent;
}
.slider:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }

/* ---- Color swatch ---- */
.swatch {
  width: 48px; height: 48px;
  border: 2px solid transparent;
  border-radius: var(--r-1);
  cursor: pointer;
  transition: border-color var(--dur-tint) var(--ease), transform var(--dur-tint) var(--ease);
}
.swatch:hover { border-color: var(--text-3); transform: translateY(-2px); }
.swatch-label { font: 400 10px/1.4 var(--font-mono); color: var(--text-3); margin-top: 4px; }

/* ---- Image zoom on hover ---- */
.zoom-img { overflow: hidden; }
.zoom-img img, img.zoom-img { display: block; width: 100%; transition: transform var(--dur-far) var(--ease); }
.zoom-img:hover img, img.zoom-img:hover { transform: scale(1.02); }

/* ---- Sticky section header ----
   Pins under the nav; when stuck it is a bar, not a card: the full width of
   the viewport, the page tone held still under the glass, the hairline as
   the only edge. JS toggles .is-stuck via an IntersectionObserver sentinel -
   see "Reveal JS" in the skill. */
.section-sticky {
  position: sticky;
  /* --pin-top, never a bar's own height: it is the height of whatever bar this
     page actually holds, derived at the root beside the navbar and the topbar,
     and a sidebar shell holds none, so the header pins to the viewport top.
     It has to be the bar that is really there, or the header pins inside one
     that outranks it on z-index and loses its top edge behind it - or, on a
     page with no bar at all, hangs a bar's height below nothing. */
  top: var(--pin-top);
  z-index: 5;
  width: 100%;
  padding: var(--space-2) 0;
  /* no tone at rest: a header in flow paints over whatever the page shows
     there (the sky, a band), and the bar's tone exists only while stuck */
  background: transparent;
  /* the hairline exists in both states so the stuck swap moves a colour,
     never adds a border pixel */
  border-bottom: 1px solid transparent;
  /* one clock for the whole swap, this the exit leg: the full-bleed
     stretch, the tint and the hairline leave together at 0.6x, on the
     slide curve the navbar docks with (the ease-out jump-and-creep note
     applies here too). */
  transition:
    width var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
    margin-inline var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
    padding-inline var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
    background var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
    border-bottom-color var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
    border-radius var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
    backdrop-filter var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
    -webkit-backdrop-filter var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1);
}
.section-sticky.is-stuck {
  /* the page tone, not the card tone: a bar is the page held still, and the
     hairline is what says it is there. An engine with no backdrop-filter and
     a viewer with reduced transparency then see one surface, not two. */
  background: var(--bg);
  --tint-ground: transparent;
  /* full-bleed: the bar spans the viewport while its toolbar stays on the
     page column, via the matching inline padding. --vp-w is the
     scrollbar-excluded viewport width the sticky controller writes;
     100vw counts a classic scrollbar and hangs a horizontal overflow off
     the page. No shadow: elevation is for floating objects, a bar is
     separated by its hairline. The three declarations that DO the full bleed are
     `.band`'s, shared with it rather than restated here. */
  /* arrival is the entrance leg, on the state arrived at, same curve */
  transition:
    width var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
    margin-inline var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
    padding-inline var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
    background var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
    border-bottom-color var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
    border-radius var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
    backdrop-filter var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
    -webkit-backdrop-filter var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1);
}
@media (prefers-reduced-motion: reduce) {
  .section-sticky, .section-sticky.is-stuck { transition: none; }
}
/* A flush card header takes the card's top radius, the same way the setting
   row's edge rows do, and paints the card tone while it rides inside the
   card; the stuck state spans the viewport as a page bar and keeps --bg. */
.card > .section-sticky:first-child {
  background: var(--surface-1);
  --tint-ground: transparent;
  border-radius: var(--r-2) var(--r-2) 0 0;
}
.card > .section-sticky.is-stuck { border-radius: 0; }
/* the header's toolbar is single-line chrome, not the labeled-field row
   .toolbar defaults to (flex-end aligns a stack of labelled fields at the
   control row); left as-is, the title bottom-locks beside the buttons */
.section-sticky .toolbar { align-items: center; }

/* ---- Scroll reveals ----
   The hiding state lives on a class JS adds at runtime (see "Reveal JS");
   markup carries only [data-reveal], so no-JS shows the final content. */
.reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity var(--dur-far) var(--ease-out), transform var(--dur-far) var(--ease-out);
}
.reveal.in { opacity: 1; transform: none; }

/* ---- Skip link ----
   Off-screen until focused, then a floating chip: the keyboard user's
   first tab lands on content, not the forty-first nav item. */
.skip-link {
  position: fixed;
  top: var(--space-4);
  left: var(--space-4);
  z-index: 1100;
  padding: var(--space-2) var(--space-4);
  background: var(--surface-1);
  --tint-ground: transparent;
  color: var(--text);
  border-radius: var(--r-pill);
  text-decoration: none;
  transform: translateY(-160%);
  /* the float shadow belongs to the revealed chip. The hidden one parks
     60px above the viewport, but its shadow still painted ~4 levels deep
     into the top-left rows (measured 2026-08-31, her "shadow on top left
     side" report); visibility:hidden would drop the link from the tab
     order, so the shadow waits for focus instead. */
  box-shadow: none;
}
.skip-link:focus { transform: none; box-shadow: var(--elev-float); }
.skip-link:hover { text-decoration: none; }

/* ---- Hover reveal ----
   Revealed by its ROW: the pointer on it, keyboard focus inside it, or an
   expanded state above it. Forced visible where hover does not exist.

   A hidden one takes no clicks. `opacity: 0` alone leaves a full-size control
   hit-testable, so three invisible `Roll back` buttons answered a press and took
   a tab stop with nothing on screen. Focus still reveals, so no control is ever
   invisible WHILE focused, which is what the keyboard needs.

   ONE reveal at a time. The pointer resting on one row while the keyboard sits on
   another painted both affordances, which reads as a stuck reveal rather than as
   two inputs. `html:has()` is the page-wide condition this system already uses for
   the dot grid; scoping it to <html> keeps it one element to evaluate. */
.reveal-hover {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-tint) var(--ease);
}
:hover > .reveal-hover,
:focus-within > .reveal-hover,
[aria-expanded="true"] .reveal-hover { opacity: 1; pointer-events: auto; }
html:has(.reveal-hover:focus-visible) :hover > .reveal-hover { opacity: 0; pointer-events: none; }
html:has(.reveal-hover:focus-visible) :focus-within > .reveal-hover { opacity: 1; pointer-events: auto; }
@media (hover: none) { .reveal-hover { opacity: 1; pointer-events: auto; } }

/* ---- Scrollbar ----
   THE NATIVE ONE IS GONE, and the reason is the cursor. A scrollbar is a UA
   shadow tree, so `cursor: none` does not reach it and the platform pointer
   paints over the ring for as long as you drag one: her call 2026-09-02, the
   same family as the stretched file input and the native `<select>` popup,
   both already retired for it. `initScrollbars` draws a real element instead,
   which the ring paints over like any other surface.

   One bar per axis for the whole page, moved to whichever scroller is active.
   That is what an overlay scrollbar already is: it needs no wrapper on any
   component, and being fixed and out of flow it cannot change a layout.

   THE HIDE IS GATED ON THE CONTROLLER, which writes `has-drawn-scrollbars` once
   its bars exist. Hidden unconditionally, a page whose module never loads would
   have no scrollbar at all, which is worse than the platform pointer this
   replaces and is the opposite of every other entrance in this system.

   THE ROOT ELEMENT IS NOT A DESCENDANT OF ITSELF, and the root's own
   `scrollbar-width` is the one the viewport reads, so a `*` sweep left the
   platform's page bar live behind the drawn one: measured on the reference
   sheet with the class on, html computed `auto` where body computed `none`. */
html.has-drawn-scrollbars,
html.has-drawn-scrollbars * { scrollbar-width: none; }
html.has-drawn-scrollbars::-webkit-scrollbar,
html.has-drawn-scrollbars *::-webkit-scrollbar { width: 0; height: 0; }

.ui-scrollbar {
  position: fixed;
  z-index: 999;   /* over the modal, under the toast stack that can cover it */
  background: var(--scrollbar-thumb);
  --tint-ground: transparent;
  border-radius: var(--r-pill);
  opacity: 0;
  pointer-events: none;
  touch-action: none;
  /* THE RING READS ITS HOVER STATE OFF THIS DECLARATION. `cursor-rules.js`
     collects every selector declaring `cursor: pointer` and the ring takes its
     hover state over that list, so the drawn thumb is a target like any other control and
     the press state follows it through a drag. The value itself never paints
     while the ring is armed: `custom-cursor` (on v3, beside `cursor-v3` or `#cursor-paint`)
     forces `cursor: none` on everything, and the bar exists only on a page whose module ran. */
  cursor: pointer;
  transition: opacity var(--dur-tint) var(--ease), background var(--dur-tint) var(--ease);
}
.ui-scrollbar.is-on { opacity: 1; pointer-events: auto; }
.ui-scrollbar:hover,
.ui-scrollbar.is-drag { background: var(--text-4); --tint-ground: transparent; }
.ui-scrollbar-y { width: 6px; }
.ui-scrollbar-x { height: 6px; }

/* ---- Divider ---- */
hr, .divider {
  height: 1px;
  background: var(--line);
  --tint-ground: transparent;
  border: none;
  margin: var(--space-6) 0;
}

/* ---- Breadcrumb ---- */
.breadcrumb {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--t-2);
  color: var(--text-3);
}
/* The chevron is drawn here rather than written into the markup, so the trail's
   punctuation cannot drift from page to page. --surface-3, which this shipped as,
   is a BACKGROUND tone standing in for a foreground: 1.67:1 dark and 1.16:1 light
   on --bg, against a 3:1 floor. --text-4 sits one step under the items it
   separates and clears it on every ground a trail sits on: 5.47 / 4.94:1 on --bg,
   4.73 / 5.55 on --surface-1, 6.07 / 4.52 on --well. */
.breadcrumb-sep { color: var(--text-4); }
.breadcrumb-sep::before {
  content: '\203A';
  /* the empty alt text keeps a drawn glyph out of the accessibility tree, and a
     parser that does not know the two-value syntax drops this line and keeps the
     one above it */
  content: '\203A' / '';
}
.breadcrumb-item {
  cursor: pointer;
  transition: color var(--dur-tint) var(--ease);
  color: var(--text-3);
  text-decoration: none;
}
.breadcrumb-item:hover { color: var(--text); text-decoration: none; }
.breadcrumb-item.current { color: var(--text); cursor: default; }

/* ---- Sidebar nav ----
   Rises with the page: surface-1, a soft hairline on the right edge only
   (a full-height divider is not a floating object, so the lit edge does
   not apply). Items carry a dot instead of an icon; the dot is the floor,
   not a placeholder. Rows are uniformly 34px tall so the indicator can
   slide on translateY and composite instead of relaying out.
   The collapsed state is the 66px rail: dots only, same indicator. */
.sidebar {
  /* --rail is the collapse knob the @property below registers and .sidebar.rail
     drives; --cbtn is the collapse button's box and the slot the logo row keeps
     for it, to its right expanded and above the mark folded. A sidebar without
     the button reserves nothing, so its logo row is the v1 row in both states. */
  --rail: 1;
  --cbtn: 26px;
  --cbtn-slot: 0px;
  --cbtn-rise: 0px;
  background: var(--surface-1);
  --tint-ground: transparent;
  border-right: 1px solid var(--line-soft);
  width: 252px;
  display: flex;
  flex-direction: column;
  /* symmetric: every block inside carries its own side insets, so the column's
     own top and bottom are the only thing holding the first and last off the
     edge. A zero here put the user row against the sidebar's bottom corner. */
  padding: var(--space-4) 0;
  position: sticky;
  top: 0;
  height: 100vh;
  /* The NAV scrolls, never the column. Scrolling the column takes the brand and
     the user row with the list: measured on the rendered sheet, a 2300px nav in
     this 100vh box pushed both out of view, because a `flex: 1 1 auto` item will
     not shrink under its content without `min-height: 0`. `hidden` on both axes
     also keeps the rail collapse from growing a horizontal scrollbar while the
     labels fold, which `overflow-y: auto` computes `overflow-x: auto` for. */
  overflow: hidden;
}
/* insets live with the rail geometry below: every one of them is a function
   of --rail, so a value here would be dead in both states */
.sidebar-logo {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  border-bottom: 1px solid var(--line-soft);
  margin-bottom: var(--space-3);
  flex-shrink: 0;   /* pinned: the nav is the only item that gives */
}
/* ---- Brand mark ----
   The brand's image slot: a squircle at the bar's control height, a 2px ring
   in --brand-ring (rose, the identity accent), and the striped placeholder
   when empty. It holds an <img> that fills it; the ring radius is inherited.
   The empty slot carries no text: the wordmark beside it keeps the accessible
   name, so the slot is decorative (aria-hidden on an empty one, alt="" on its
   img). The stripe reuses .img-placeholder's pattern on --well, never a new
   literal. */
.sona {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: var(--r-1);
  border: 2px solid var(--brand-ring);
  background-color: var(--well);
  --tint-ground: transparent;
  background-image: repeating-linear-gradient(
    -45deg,
    color-mix(in srgb, var(--text-4) 18%, var(--tint-ground)) 0 1px,
    transparent 1px 9px
  );
  flex-shrink: 0;
}
.sona img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
}
/* the sidebar logo row has room for a larger mark */
.sidebar-logo > .sona {
  width: 36px;
  height: 36px;
}
/* under 768 the navbar hides the wordmark; the sona matches the mark size it
   replaced so the nav link band keeps its width */
@media (max-width: 768px) {
  .navbar-brand > .sona {
    width: 28px;
    height: 28px;
  }
}
.logo-name {
  font: 600 var(--t-4)/1.25 var(--font-sans);
  letter-spacing: -0.02em;
  color: var(--text);
}
.sidebar-search {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
  height: 34px;
  margin: 0 var(--space-3) var(--space-3);
  /* inline padding lives with the rail geometry: `.sidebar .sidebar-search`
     derives it from --rail and outranks anything stated here */
  background: var(--well);
  --tint-ground: transparent;
  border-radius: var(--r-1);
  color: var(--text-3);
  font-size: var(--t-2);
}
.sidebar-search svg { width: 13px; height: 13px; flex-shrink: 0; }
.sidebar-search kbd {
  font: 500 10px/1 var(--font-mono);
  background: var(--surface-1);
  --tint-ground: transparent;
  border: none;
  padding: 3px 6px;
  border-radius: 5px;
  color: var(--text-3);
  margin-left: auto;
}
/* min-height: 0 is what lets this shrink under its own content, so a nav with
   more rows than the viewport scrolls here instead of pushing the rows above and
   below it out of the column. */
.sidebar-nav { flex: 1 1 auto; min-height: 0; overflow: hidden auto; padding: 0 var(--space-3); }
.nav-group { margin-bottom: var(--space-3); }
.nav-group-label {
  height: 26px;
  display: flex;
  align-items: center;
  padding: 0 var(--space-2);
  color: var(--text-4);
}
.nav-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  height: 34px;
  width: 100%;
  padding: 0 var(--space-2);
  font: 400 var(--t-3)/1 var(--font-sans);
  color: var(--text-3);
  cursor: pointer;
  border: none;
  background: none;
  border-radius: var(--r-1);
  text-align: left;
  text-decoration: none;
  user-select: none;
  transition: color var(--dur-tint) var(--ease), background var(--dur-tint) var(--ease);
}
.nav-item:hover { color: var(--text-2); background: var(--surface-2); --tint-ground: transparent; }
.nav-item.active { color: var(--text); font-weight: 500; }
/* The active row's band is one rule across the three rows that paint one. It
   is declared down in the tree block, the last of the three hover rules it has
   to outrank: `.nav-item:hover` above, `.menu-item:hover` for the palette and
   `.tree-row:hover` below all tie it at (0,2,0), and a hovered selected row
   keeps its accent rather than repainting grey, the treatment
   `.menu-item.active` already had. The shape is NOT here: each row carries
   --r-1 at rest, so the band arrives on a corner that is already rounded and
   nothing animates. ONE accent mark appears per row, and it is this band: her
   call 2026-09-02 retired the 2px capsule the tree and the palette added on
   top of it, for the reason .nav-indicator went on 2026-08-29. The sidebar's
   dot is not a second mark, since it is on every row at rest and only changes
   shape here. */
/* The dot IS the marker: the active row's own dot grows into a capsule, and the
   leaving row's shrinks back, on the same frame. One element changing shape,
   never two swapping. r-pill instead of 50% so the grown state stays a capsule;
   at 6x6 both clamp to the same 3px circle. */
.nav-dot {
  width: 6px;
  height: 6px;
  border-radius: var(--r-pill);
  flex-shrink: 0;
  background: var(--text-4);
  --tint-ground: transparent;
  transition:
    height var(--dur-near) var(--ease),
    background var(--dur-tint) var(--ease),
    box-shadow var(--dur-tint) var(--ease);
}
.nav-item:hover .nav-dot { background: var(--text-3); --tint-ground: transparent; }
.nav-item.active .nav-dot { height: var(--space-4); background: var(--accent); --tint-ground: transparent; box-shadow: var(--accent-glow); }
.nav-item svg { width: 14px; height: 14px; flex-shrink: 0; opacity: var(--icon-dim); }
.nav-item.active svg { opacity: 1; }
.nav-badge {
  font: 500 10px/1 var(--font-mono);
  background: var(--surface-2);
  --tint-ground: transparent;
  color: var(--text-3);
  border-radius: var(--r-pill);
  padding: 3px 7px;
  margin-left: auto;
  transition: background var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease);
}
/* on the active row the badge drops its own fill and sits on the band, her
   pick 2026-10-09: a quiet fill on the quiet band read 4.41:1 dark on the
   sidebar. On the band it reads 5.82:1 dark and, with the shared remix, 5.48
   light. */
.nav-item.active .nav-badge { background: transparent; color: var(--accent-text); }
.sidebar-user {
  margin-top: var(--space-2);
  padding-block: var(--space-4) 0;   /* inline side is a --rail function below */
  border-top: 1px solid var(--line-soft);
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;   /* pinned: the nav is the only item that gives */
}
.sidebar-user .avatar { width: 28px; height: 28px; font-size: 10px; }
.sidebar-user-name { font-size: var(--t-2); color: var(--text-2); flex: 1; }
.sidebar-user-plan { font: 400 var(--t-1)/1 var(--font-mono); color: var(--text-4); }

/* ---- Rail collapse: 252px to 66px, animated ----
   --rail is the whole move: 1 is the 252px column, 0 the 66px rail.
   Registered so it interpolates, and every geometry value below is a
   function of it, the one-number pattern .select's --fold set.
   NAMED DEVIATION from the transform-and-opacity-only contract: the
   move animates width, padding and margin, because a rail collapse
   changes where the page column starts and no transform reflows it.
   The rejected alternative was a transform-only translateX clip: it
   overlays the page instead of reflowing, leaves the labels clipped
   under a closing edge with no layout behind it, and the settled
   66px rail would still need the same width swap at the end.
   The controller is one class move: sidebar.classList.toggle('rail').
   No measuring, no transitionend, no resize handler. */
@property --rail { syntax: "<number>"; inherits: true; initial-value: 1; }

.sidebar:has(> .sidebar-logo > .sidebar-collapse) {
  --cbtn-slot: calc(var(--cbtn) + var(--space-2));
  --cbtn-rise: max(0px, (var(--cbtn) - 28px) / 2);   /* a button taller than the mark */
}
.sidebar.rail {
  --rail: 0;
  width: 66px;
}
/* expanding back is an entrance, folding away an exit at 0.6x. Scoped to
   the desktop shell: under 768 the sidebar is the drawer, whose own
   transition list must not be overwritten.
   width interpolates on its own leg of the same curve, off the v1 values
   (252px and 66px), NOT off calc(--rail): chrome resolves a descendant's
   calc off the animating --rail but not the element's own width (measured
   2026-08-30: --rail at 0.82/0.28/0.10 with width pinned at the end value,
   nav items tracking 60.4/47.1/42.5), and a width that both transitions
   and recomputes from the moving --rail chases a moving target and lags
   the whole move. Same duration and easing on both legs, so width and the
   derived geometry land on the same curve: 252 - 186 * e(t) either way. */
@media (min-width: 769px) {
  .sidebar { transition: --rail var(--dur-far) var(--ease-out), width var(--dur-far) var(--ease-out); }
  .sidebar.rail { transition: --rail var(--dur-out-far) var(--ease), width var(--dur-out-far) var(--ease); }
}

/* ---- Geometry, all functions of --rail ----
   Rows clip their labels at their own edge, so the labels slide under the
   closing column as they fade instead of pushing a scrollbar.
   EVERY VALUE HERE IS MONOTONE IN --rail. A value that divides a shrinking
   100% is not: the row centring used to read (100% - 40px) / 2, which peaks
   mid-fold, and the dots swung 20px out to 45 and back to 30 (measured).
   Both end values are lengths, so both insets interpolate as lengths. */

/* the mark sits 16px from the edge expanded, dead centre (19px) folded.
   nowrap: at 11px of free width the name wrapped to two lines and grew the
   row 17.5px in one frame, at 9% opacity, taking the whole column with it. */
.sidebar .sidebar-logo {
  position: relative;   /* the collapse button travels on insets, not on flex order */
  overflow: hidden;
  white-space: nowrap;
  /* `overflow: hidden` zeroes a flex item's automatic minimum size, so a nav
     taller than the column crushes this row instead of scrolling: 49px to
     17px with the mark clipped, measured in a 620px shell. Same for the
     search well and the user row. */
  flex-shrink: 0;
  padding-block: calc(var(--cbtn-rise) * var(--rail) + var(--cbtn-slot) * (1 - var(--rail))) var(--space-4);
  padding-inline: calc(var(--space-4) + 3px * (1 - var(--rail)))
                  calc(var(--space-4) + var(--cbtn-slot) * var(--rail));
  border-bottom-color: color-mix(in srgb, var(--line-soft) calc(var(--rail) * 100%), var(--tint-ground));
}

/* the brand block clips at the slot's edge. The row clips at its PADDING box,
   which is outside the slot, so a name longer than the column paints under
   the chevron; and a nowrap flex item will not shrink past its text without
   an explicit min-width. */
.sidebar .sidebar-logo > :not(.sona):not(.sidebar-collapse) {
  min-width: 0;
  overflow: hidden;
}

/* the search keeps its 12px side insets: 227px expanded, a 41px well folded,
   its icon centred by padding rather than a justify swap. Those two numbers
   are the COLUMN MINUS ITS BORDER minus the 24px of margin: the border-right
   is inside the declared 252px, so a 228px well overflowed the 251px content
   box by a pixel, and `overflow-y: auto` computes `overflow-x` to auto, which
   is a horizontal scrollbar on the sidebar in every state. */
.sidebar .sidebar-search {
  overflow: hidden;
  flex-shrink: 0;
  width: calc(41px + 186px * var(--rail));
  padding-inline: calc(var(--space-2) + 6px * (1 - var(--rail)))
                  calc(var(--space-2) * var(--rail));
}

.sidebar .sidebar-nav { padding-inline: calc(var(--space-3) * var(--rail)); }

/* group labels stay in layout as their own height: no row jumps at frame 0
   of the move. The folded rail keeps the group spacing v1 packed away. */
.sidebar .nav-group-label { overflow: hidden; }

/* a row spans the column expanded and is a centred 40px pill folded: 12.5px
   of margin each side folded, 25px off the width to pay for both. A button
   sizes `width: auto` as fit-content whatever its display, so the width is
   stated. The dot lands 20px from the edge expanded (12px nav + 8px item
   padding) and dead centre folded (12.5px margin + 17px padding), and every
   step between is on that line: width is 40 + 187 * rail throughout. */
.sidebar .nav-item {
  overflow: hidden;
  white-space: nowrap;
  width: calc(100% - 25px * (1 - var(--rail)));
  margin-inline: calc(12.5px * (1 - var(--rail)));
  padding-inline: calc(var(--space-2) + 9px * (1 - var(--rail)))
                  calc(var(--space-2) * var(--rail));
}
/* the dot keeps its 6px box in the rail; the 1px v1 bump was invisible */
.sidebar.rail .nav-dot { width: 6px; height: 6px; }

/* ---- The collapse button ----
   Top right of the logo row expanded, centred above the mark folded. Its
   insets are functions of --rail like everything else, so it rides the
   closing edge in instead of jumping rows, and the chevron turns with the
   same number rather than swapping icons. Absolute, so the row's flex line
   is the brand alone at every width. */
.sidebar-collapse {
  position: absolute;
  /* 64 rather than 66: an inset resolves off the padding box, which is the
     column minus its border, and the mark's centre sits at 33 in it */
  inset-block-start: calc(max(0px, (28px - var(--cbtn)) / 2) * var(--rail));
  inset-inline-end: calc(var(--space-4) * var(--rail) + (64px - var(--cbtn)) / 2 * (1 - var(--rail)));
  width: var(--cbtn);
  height: var(--cbtn);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  background: none;
  border-radius: var(--r-1);
  color: var(--text-4);
  cursor: pointer;
  transition: color var(--dur-tint) var(--ease), background var(--dur-tint) var(--ease);
}
/* 4.73:1 dark and 5.55:1 light at rest, 6.17 and 7.00 on the hover fill */
.sidebar-collapse:hover { color: var(--text-2); background: var(--surface-2); --tint-ground: transparent; }
.sidebar-collapse svg {
  width: 14px;
  height: 14px;
  transform: rotate(calc(180deg * (1 - var(--rail))));
}

/* the user row centres the avatar the same way: 16px from the edge
   expanded, 19px folded. Both sides carry the inset, or the plan text
   sits against the sidebar's right edge. */
.sidebar .sidebar-user {
  overflow: hidden;
  flex-shrink: 0;
  padding: var(--space-4) 0 0;
  padding-inline: calc(var(--space-4) + 3px * (1 - var(--rail)));
}

/* ---- Labels: a crossfade on its own clock ----
   Deliberately NOT derived from --rail: reduced motion snaps the geometry
   and keeps this fade, so the two cannot share one number. The fade-out
   runs at 0.6x like every exit; visibility flips only after it completes.
   THE ROW'S OWN LABEL IS EXEMPT from that flip: it is the accessible name of
   a button that stays focusable in the rail, and `visibility: hidden` takes
   it out of the accessibility tree, leaving five focusable rows with no name.
   Opacity alone hides it, and the row already clips its own overflow.
   Scoped to the desktop shell, or a fold followed by a resize below 768
   leaves the drawer at 252px with every label gone. */
.sidebar .logo-name,
.sidebar .sidebar-search span, .sidebar .sidebar-search kbd,
.sidebar .nav-group-label,
.sidebar .nav-item > span:not(.nav-dot), .sidebar .nav-item > svg,
.sidebar .nav-item > .nav-badge,
.sidebar .sidebar-user-name, .sidebar .sidebar-user-plan {
  visibility: visible;
  transition: opacity var(--dur-near) var(--ease-out), visibility 0s;
}
@media (min-width: 769px) {
  .sidebar.rail .logo-name,
  .sidebar.rail .sidebar-search span, .sidebar.rail .sidebar-search kbd,
  .sidebar.rail .nav-group-label,
  .sidebar.rail .nav-item > span:not(.nav-dot), .sidebar.rail .nav-item > svg,
  .sidebar.rail .nav-item > .nav-badge,
  .sidebar.rail .sidebar-user-name, .sidebar.rail .sidebar-user-plan {
    /* display stays block: display:none killed the fade at frame 0 */
    display: block;
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity var(--dur-out-near) var(--ease),
                visibility 0s linear var(--dur-out-near);
  }
  .sidebar.rail .nav-item > span:not(.nav-dot) {
    visibility: visible;
    transition: opacity var(--dur-out-near) var(--ease);
  }
}

/* ---- Reduced motion: the geometry snaps, the fade stays ----
   No --rail or width transition: every geometry value snaps with the class,
   and the label rules above keep their own opacity transition untouched.
   Scoped off the drawer's own reduced-motion block. */
@media (prefers-reduced-motion: reduce) and (min-width: 769px) {
  .sidebar, .sidebar.rail { transition: none; }
}

/* the drawer owns the narrow viewport: the rail class must not reshape it,
   so the control that sets that class has nothing to do and is not shown */
@media (max-width: 768px) {
  .sidebar, .sidebar.rail { --rail: 1; width: 252px; }
  .sidebar-collapse { display: none; }
  /* the slot goes with the button: a hidden control must not keep 34px of the
     brand row. Restated at the selector that set it, since the media query
     adds no specificity and `:has()` carries its argument's. */
  .sidebar:has(> .sidebar-logo > .sidebar-collapse) { --cbtn-slot: 0px; --cbtn-rise: 0px; }
}

/* ---- Top navbar ----
   One scroll class, two states. The resting band: full width, surface-1,
   the line-soft hairline, opaque. At the top of the page (.is-top) it
   lifts into a floating card: page-width, rounded, the card shadow, no
   hairline. Opaque in both - the navbar never takes the glass, because at
   the top nothing passes behind it and a full-width blurred band is the
   slab the glass strength round measured out. The underline slides on
   transform, same physics as the tab ink. */
.navbar {
  display: flex;
  align-items: stretch;
  height: var(--navbar-h);
  padding: 0 var(--space-8);
  margin-inline: auto;
  background: var(--surface-1);
  --tint-ground: transparent;
  border-bottom: 1px solid var(--line-soft);
  position: sticky;
  top: 0;
  z-index: 100;
}
/* the pin derivation, her call 2026-09-04: a page holding this bar starts its
   pinned things under it, and a page holding none pins to the viewport top.
   --navbar-h itself stays the bar's own height and nothing else. */
html:has(.navbar) { --pin-top: var(--navbar-h); }
/* The width change is a NAMED DEVIATION, same class as the rail collapse:
   a bar resizing its own row; nothing below it reflows. Arrival at the
   band is the entrance leg, the return to the card the 0.6x exit, each
   declared on the state arrived at. Scoped to the desktop shell, where
   the narrow icon state owns the under-768 breakpoint. Auto margins are
   never in the transition: they resolve against the animated width every
   frame, so the card centres itself throughout the morph.

   Both legs run the slide curve, the one the inks run. --ease-out
   front-loads 88 percent of its travel, which on a width change reads as
   a jump followed by a linear creep (her note, 2026-08-31: "less linear
   animation curve"); the slide curve starts gentle, moves quick through
   the middle and lands soft. The card also floats --space-3 off the top
   edge, on transform and never on margin: a margin reflows the page
   below on every swap, a transform only moves the bar. Reduced motion
   keeps the float and snaps the moves (transition: none below). */
@media (min-width: 769px) {
  .navbar {
    width: 100%;
    transition:
      transform var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
      width var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
      border-radius var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
      box-shadow var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1),
      border-bottom-color var(--dur-far) cubic-bezier(0, 0.85, 0.35, 1);
  }
  .navbar.is-top {
    width: min(var(--page-w), 100%);
    transform: translateY(var(--space-3));
    border-radius: var(--r-2);
    box-shadow: var(--elev-1);
    border-bottom-color: transparent;
    transition:
      transform var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
      width var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
      border-radius var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
      box-shadow var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1),
      border-bottom-color var(--dur-out-far) cubic-bezier(0, 0.85, 0.35, 1);
  }
  /* the ink overlaps the band's border by 1px; inside the card it tucks
     under the rounded edge instead of crossing it */
  .navbar.is-top .navbar-ink { bottom: 0; }
  /* the dock's cover strip: while the card is away, a fixed page-toned
     band sits over the float's gap, so the transform can glide the bar
     home instead of snapping and nothing paints in the strip at any
     scroll speed (her fast-flick report, 2026-08-31). It lands the frame
     scrolling starts, hides behind the docked band, and is gone at rest,
     where the dot grid owns the strip.

     Written as the condition that PAINTS the strip. As the condition that hid
     it, the :has() had to find a navbar before the strip could go, so a page
     carrying no navbar at all painted the band permanently across the top of its
     own content: measured on seven of these pages, up to 2218 differing pixels
     at one scroll offset, and 0 on the one page that already overrode it. */
  body:has(.navbar):not(:has(.navbar.is-top))::before {
    content: '';
    position: fixed;
    inset: 0 0 auto 0;
    height: calc(var(--space-3) + 1px);
    background: var(--bg);
    --tint-ground: transparent;
    z-index: 99;
  }
}
@media (prefers-reduced-motion: reduce) and (min-width: 769px) {
  .navbar, .navbar.is-top { transition: none; }
}
.navbar-brand {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--space-2);
  padding-right: var(--space-5);
  margin-right: var(--space-3);
  border-right: 1px solid var(--line-soft);
}
/* The content column inside a full-bleed bar, and there is only one of them: the
   navbar's and the banner's are the same box. Width is --page-w MINUS the page's
   own inline padding, because .page carries that padding inside its max-width,
   so a plain --page-w column lands 32px outside the page's first word (measured
   120 against 152 at a 1440 viewport). It holds at every width, since both drop
   to --space-4 at the same breakpoint. Wrap a bar's children in this whenever
   the page uses .page. */
.navbar-inner,
.banner-inner {
  max-width: calc(var(--page-w) - 2 * var(--space-8));
  margin-inline: auto;
  width: 100%;
  display: flex;
  gap: var(--space-3);
}
.navbar-inner {
  height: 100%;
  /* stretch, never center: center collapses each child to its own height, so
     .navbar-nav shrinks to its links and the ink rides the label instead of
     the bar. The brand and the actions self-centre; the nav fills the bar. */
  align-items: stretch;
}
.navbar-nav {
  display: flex;
  align-items: stretch;
  gap: 0;
  position: relative;
  flex: 1;
}
.navbar-link {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: 0 var(--space-4);
  font: 400 var(--t-3)/1 var(--font-sans);
  color: var(--text-2);
  cursor: pointer;
  text-decoration: none;
  background: transparent;
  border: none;
  transition:
    color var(--dur-tint) var(--ease),
    background var(--dur-tint) var(--ease);
  user-select: none;
  white-space: nowrap;
}
.navbar-link:hover  { color: var(--text); background: var(--surface-2); --tint-ground: transparent; text-decoration: none; }
.navbar-link.active { color: var(--accent-text); font-weight: 500; }
.navbar-link svg    { width: 14px; height: 14px; flex-shrink: 0; opacity: var(--icon-dim); }
.navbar-link.active svg { opacity: 1; }



.navbar-actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-left: auto;
  padding-left: var(--space-3);
}
.navbar-search {
  position: relative;
  width: 240px;
  /* a stretched child would top-align its 30px input in the 55px bar */
  display: flex;
  align-items: center;
}
.navbar-search .input { height: var(--ctl-sm); padding: 0 10px 0 30px; font-size: var(--t-2); }
.navbar-search .input-icon { left: 9px; }

/* ---- Mobile hamburger / drawer (shared by sidebar and navbar) ---- */
.mobile-topbar {
  display: none;
  align-items: center;
  justify-content: space-between;
  padding: 0 var(--space-4);
  height: var(--topbar-h);
  background: var(--surface-1);
  --tint-ground: transparent;
  border-bottom: 1px solid var(--line-soft);
  position: sticky;
  top: 0;
  z-index: 200;
}
.hamburger {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--ctl);
  min-width: var(--ctl);
  height: var(--ctl);
  padding: 0;
  background: none;
  border: 1px solid var(--line);
  border-radius: var(--r-1);
  cursor: pointer;
  color: var(--text-3);
  transition: border-color var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease);
}
/* every other icon container sizes its own glyph, and this one did not: an svg
   with a viewBox and no size inside a shrink-to-fit box resolves to 0x0, so a
   hamburger written without width and height attributes painted an empty square */
.hamburger svg { width: 16px; height: 16px; }
.hamburger:hover { border-color: var(--surface-3); color: var(--text); }

.sidebar-overlay {
  display: none;
  position: fixed;
  inset: 0;
  background: var(--backdrop);
  z-index: 299;
  opacity: 0;
  transition: opacity var(--dur-near) var(--ease);
}
.sidebar-overlay.open { opacity: 1; }

@media (max-width: 768px) {
  .mobile-topbar { display: flex; }
  /* the topbar's own display condition: under it, a page holding the topbar
     re-derives the pin at the mobile bar's height. Scoped to the bar's
     presence and not written bare, so a narrow page holding a navbar and no
     topbar keeps the navbar's 56; ties the navbar derivation at (0,1,1) and
     wins by coming after it. */
  html:has(.mobile-topbar) { --pin-top: var(--topbar-h); }
  .sidebar {
    position: fixed;
    top: 0;
    left: 0;
    height: 100vh;
    height: 100dvh;   /* vh lies under mobile browser chrome */
    z-index: 300;
    transform: translateX(-100%);
    transition:
      transform var(--dur-cross) var(--ease-out),
      box-shadow var(--dur-cross) var(--ease);
    width: 252px;
  }
  .sidebar.open {
    transform: translateX(0);
    box-shadow: var(--elev-float);
  }
  .sidebar-overlay { display: block; pointer-events: none; }
  .sidebar-overlay.open { pointer-events: all; }
  /* The nav scrolls in its own band instead of vanishing behind a drawer
     the page may not ship. Three projects made this call independently.
     The band is the scroller: overflow-x on the navbar itself would carry
     the brand and the actions off with it. min-width: 0 lets the flex item
     shrink below its nowrap content; the ink rides the scroll with the
     links it sits under. */
  .navbar-nav { display: flex; min-width: 0; overflow-x: auto; }
  .navbar-search { display: none; }
  /* 16px on focusable fields: iOS zooms the whole viewport otherwise. */
  .input, textarea.input { font-size: var(--t-4); }
}

/* ---- Coarse pointers get the 44px touch floor ---- */
@media (pointer: coarse) {
  /* The three button sizes collapse onto the floor here, and that is the
     intent stated rather than an accident of source order: .btn-sm and
     .btn-lg match .btn at (0,1,0) and this block is later in the file, so
     they cannot keep 30 and 36. Padding and type size are what separate the
     three on touch. */
  .btn, .btn-sm, .btn-lg { height: var(--ctl-lg); min-height: var(--ctl-lg); }
  /* an icon button is square, so the floor has to reach its width too, or it
     lands 36 wide by 44 tall exactly where the 44px rule applies */
  .btn-icon { width: var(--ctl-lg); min-width: var(--ctl-lg); }
  .hamburger { width: var(--ctl-lg); height: var(--ctl-lg); }
  input.btn { line-height: 42px; }
  .input { height: var(--ctl-lg); }
  .sidebar-search { height: var(--ctl-lg); }
  .nav-item { height: 44px; }
  /* the sidebar's row took the floor and the navbar's did not: measured 14px on
     a Pixel 7, the shortest tap target this system shipped. The ink is placed
     off the link's own box, so the height goes on the link rather than on a
     wrapper, and the link centres its own label. */
  .navbar-link { min-height: var(--ctl-lg); display: flex; align-items: center; }
  /* the logo row grows with it: the slot and the rise both read --cbtn */
  .sidebar { --cbtn: var(--ctl-lg); }
  /* menu and listbox rows are a tap target like any other: 8px padding around
     14px text lands at 37.7, six short of the floor */
  .menu-item { min-height: var(--ctl-lg); }
  /* padding alone cannot promise the floor: the tab's own line-height and font
     size decide the rest, and the pair measured 40.8px */
  .tab { padding-top: 12px; padding-bottom: 12px; min-height: var(--ctl-lg); }
  /* A segment carries the floor itself: a segmented control has no row to hand
     it to, and the track grows with the segment, so the ink follows off its own
     `calc(100% - 6px)` with no rule here. Measured 26.4px. */
  .tab-box { min-height: var(--ctl-lg); }
  /* A field is its own target and its ROW cannot stand in for it: nothing
     associates .palette-search's padding with the input, so a tap there focuses
     nothing. The field takes the floor and the row gives up its block padding,
     which keeps the row at 44 rather than stacking to 60. Measured 22.4px. */
  .palette-search { padding-block: 0; }
  .palette-input { min-height: var(--ctl-lg); }
  .checkbox-label input[type="checkbox"], .radio-label input[type="radio"] { width: 20px; height: 20px; }
  /* a 44px switch or checkbox is the heaviest thing on a phone screen, so these three
     keep their proportions and their row carries the tap floor instead */
  .toggle-wrap, .checkbox-label, .radio-label { min-height: var(--ctl-lg); }
  .toggle { width: 44px; height: 26px; }
  .toggle::after { width: 20px; height: 20px; }
  .toggle.on::after { transform: translateX(18px); }
  /* a breadcrumb row is 18.6px of text on a phone with nothing to catch it */
  .breadcrumb-item { display: inline-flex; align-items: center; min-height: var(--ctl-lg); }
}

/* ---- Reduced motion: replaces, never removes ----
   Fades stay, transforms go. Nothing waits on transitionend, so no state
   feedback is lost and no script is stranded. */
@media (prefers-reduced-motion: reduce) {
  .card-interactive:hover, .swatch:hover { transform: none; }
  .toast { animation: fade-in var(--dur-near) var(--ease) both; }
  .toast.removing { animation: fade-out var(--dur-out-near) var(--ease) both; }
  .modal { animation: fade-in var(--dur-near) var(--ease) both; }
  .modal-backdrop.closing .modal { animation: fade-out var(--dur-out-far) var(--ease) both; }
  .popover { animation: fade-in var(--dur-near) var(--ease) both; }
  /* at (0,2,0), because the base exit is too and this block adds no specificity.
     The daterange panel rides along for the same reason it rides the base. */
  .popover.closing,
  .daterange.closing > .daterange-panel { animation-name: fade-out; }
  .reveal { transform: none; }
  .zoom-img img, img.zoom-img { transform: none; }
  .tab-ink, .navbar-ink, .tabs-boxed-ink { transition: opacity var(--dur-tint) var(--ease); }
  /* the dot's size snaps, its colour still fades. Restated at the base
     selector's own specificity, since the rule above it is (0,1,0) too. */
  .nav-dot { transition: background var(--dur-tint) var(--ease), box-shadow var(--dur-tint) var(--ease); }
  /* the panel fades instead of folding, so the seal has no edge to meet: the
     button's costume tracks the fade itself, corners and outline landing with
     the last of the panel. --fold still runs, as the fade's own progress. */
  .select { transition: --fold var(--dur-out) linear; --seal: var(--fold); --lift: calc(1 - var(--fold)); }
  .select.open { transition: --fold var(--dur-near) linear; }
  /* the up variant's own clip and transform are (0,2,0) and outrank a bare
     `.select-menu`, so without them restated the panel fades in and out while
     clipped to nothing */
  .select-menu, .select-up > .select-menu { clip-path: none; transform: none; opacity: 0; transition: opacity var(--dur-out) var(--ease), visibility 0s linear var(--dur-out); }
  .select.open > .select-menu { opacity: 1; transition: opacity var(--dur-near) var(--ease), visibility 0s; }
  .select-caret, .select.closing .select-caret { transition: color var(--dur-tint) var(--ease); }
  /* the state-change marks keep their fade and drop the scale, restated at the
     base selector's own specificity so the (0,2,2) rules above cannot outrank */
  .checkbox-label input[type="checkbox"]::after,
  .radio-label input[type="radio"]::after { transition: opacity var(--dur-near) var(--ease-out); }
  /* the knob arrives; the track's own fill still fades */
  .toggle::after { transition: none; }
  .tab-panel.active,
  .form-summary { animation-name: panel-in-fade; }
  .code-copy .icon-check { animation: none; }
}
/* the reduce fallbacks are three bodies, not one per component: `panel-in-fade`
   holds the element's own end opacity, `fade-in` forces it to 1, and
   `fade-out` is the exit every one of them leaves on. */
@keyframes panel-in-fade { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) and (max-width: 768px) {
  .sidebar { transform: none; opacity: 0; transition: opacity var(--dur-out-far) var(--ease); }
  .sidebar.open { opacity: 1; }
}

/* ---- Theme switch ----
   The dataset.theme swap crossfades the whole page. document.startViewTransition
   snapshots the old frame, runs the swap and fades the two together. The old
   frame is an exit and takes the 0.6x token; the new one arrives on --dur-far,
   the full-page step of the ladder. Opacity only: the root snapshot keeps the
   viewport's geometry, so the group has no size change to animate. --ease, not
   --ease-out: the arrival curve is for something the eye follows across a
   distance, and a crossfade offers no such mark. Without startViewTransition
   (Firefox, Safari today) the page swaps on the spot. Measured: the visible
   switch is the old frame's 156ms exit; the new frame's remaining ramp changes
   nothing once the old snapshot is gone. */
::view-transition-old(root) {
  animation-duration: var(--dur-out-far);
  animation-timing-function: var(--ease);
}
::view-transition-new(root) {
  animation-duration: var(--dur-far);
  animation-timing-function: var(--ease);
}
/* the crossfade is already a pure fade, so reduced motion keeps it. The group
   is the only pseudo that can carry a transform; pinning it off here rather
   than leaving it to the viewport happening not to resize. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(root) { animation: none; }
}

/* The toggle's icon pair: both glyphs sit in one grid cell, the theme's
   active glyph at full opacity and the other faded and shrunk, so the swap
   crossfades instead of popping. [data-theme-toggle] marks the button. */
[data-theme-toggle] {
  display: grid;
  place-items: center;
}
[data-theme-toggle] > svg {
  grid-area: 1 / 1;
  transition: opacity var(--dur-near) var(--ease), transform var(--dur-near) var(--ease-out);
}
html[data-theme="dark"] [data-theme-toggle] .icon-moon,
html[data-theme="light"] [data-theme-toggle] .icon-sun {
  opacity: 0;
  transform: scale(0.6);
}
@media (prefers-reduced-motion: reduce) {
  html[data-theme="dark"] [data-theme-toggle] .icon-moon,
  html[data-theme="light"] [data-theme-toggle] .icon-sun {
    opacity: 0;
    transform: none;
  }
}

/* ---- Table patterns: sort, selection, pagination, filter bar ----
   Four patterns off the shipped .table-wrap / .tag / .checkbox-label / .btn
   primitives; no new surface, no new text level. Ratios measured with
   tools/contrast.mjs. */

/* ---- Sortable header ----
   The button is the clickable cell content; aria-sort stays on the th,
   where assistive tech reads it. The indicator is one fixed box holding
   both chevrons, so only fills change: the glyph can never shift the
   column when the direction changes, which is what a swapping icon
   does to every row width. */
.th-sort {
  display: inline-flex;
  align-items: center;
  /* an inline-flex sits on its own BASELINE, and the baseline of a flex box is its
     first item's. `th.num-col .th-sort` reverses the order, so the glyph becomes the
     first item and the whole button moves. Two effects, measured separately against
     a control (a 0.5px nudge read back as exactly 0.5): in the pre-change tree the
     num-col label sat 1.0px above every other label in its row, and the `.th-label`
     box below then lifted BOTH sortable labels a further 1.0px. Aligning the box
     removes the dependence on which child comes first and cures both, so the row
     reads one label top. The first of those is a defect this system already had;
     the second is one this change would otherwise have introduced. */
  vertical-align: middle;
  gap: 6px;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: inherit;
  cursor: pointer;
  /* no control-height floor on a fine pointer: a header is not a control row, and
     the 30px one made a sortable header 50.5px against a plain one's 31.5, so the
     same table header rendered two heights. It is 36.5 now. The 44px tap floor
     lives in the coarse block and is untouched. Her call 2026-09-03. */
  transition: color var(--dur-tint) var(--ease);
}
.th-sort:hover { color: var(--text); }

/* One fixed box holding two sprite glyphs in one grid cell, the construction the
   theme toggle's sun and moon already use: `#i-sort` while the column is unsorted,
   the direction chevron once it is, turned for descending. Her call 2026-09-02
   squared this box so a 16-viewBox symbol could drop in; the pair of hand-drawn
   8 x 4 chevrons it replaces was the last glyph in the system no guard could read.
   The box stays fixed at every state, so the glyph can never shift the column. */
.th-sort-icon {
  display: inline-grid;
  place-items: center;
  flex-shrink: 0;
  width: 14px;
  height: 14px;
  color: var(--text-4);   /* 6.06:1 on --well */
  transition: color var(--dur-tint) var(--ease);
}
/* light header row sits on --surface-2, where text-4 falls to 4.46:1 */
[data-theme="light"] .th-sort-icon { color: var(--text-3); }   /* 4.94:1 */
.th-sort-icon > svg { grid-area: 1 / 1; width: 14px; height: 14px; display: block; }
.th-sort-icon > .sort-dir { visibility: hidden; }
/* the sorted column keeps its box and only fills: dark 10.13:1 on --well,
   light 4.76:1 on --surface-2 */
th:is([aria-sort="ascending"], [aria-sort="descending"]) .th-sort-icon {
  color: var(--accent-text);
}
th:is([aria-sort="ascending"], [aria-sort="descending"]) .th-sort-icon > .sort-rest { visibility: hidden; }
th:is([aria-sort="ascending"], [aria-sort="descending"]) .th-sort-icon > .sort-dir { visibility: visible; }
th[aria-sort="descending"] .th-sort-icon > .sort-dir { transform: rotate(180deg); }
/* a numeric header keeps its button on the right edge with the glyph after
   the label, matching the right-aligned column below it */
th.num-col .th-sort { flex-direction: row-reverse; }

/* ---- Row selection ----
   The tint holds surface-1's own lightness and rotates the hue toward
   sapphire instead of rising: on dark, accent mixed into the well lands at
   L .303 against surface-1's .300, so a selected row reads tinted, not
   raised, and stays clear of hover (--surface-2, L .345). Light tints
   surface-1 down a step, the direction light pressed states go. Every text
   level and every tag pair clears 4.5:1 on both tints:
   dark ramp 9.91 / 7.14 / 5.38 / 4.65:1, worst tag pair 4.65:1;
   light ramp 11.46 / 7.14 / 5.02 / 4.52:1, worst tag pair 4.53:1.
   The edge marker is the system's active-row inset, not a new device. */
/* the tint lands over a transition on the row itself: the controller
   toggles the class in place, never re-renders the row set for it */
.table-wrap tbody tr {
  transition: background var(--dur-tint) var(--ease), box-shadow var(--dur-tint) var(--ease);
}
.table-wrap tbody tr.row-selected {
  background: color-mix(in srgb, var(--accent) 14%, var(--well));
  --tint-ground: transparent;
  box-shadow: inset 2px 0 0 var(--accent);
}
[data-theme="light"] .table-wrap tbody tr.row-selected {
  background: color-mix(in srgb, var(--accent) 12%, var(--surface-1));
  --tint-ground: transparent;
}
/* selection outranks hover: the row keeps its state under the pointer */
.table-wrap tbody tr.row-selected:hover {
  background: color-mix(in srgb, var(--accent) 14%, var(--well));
  --tint-ground: transparent;
}
[data-theme="light"] .table-wrap tbody tr.row-selected:hover {
  background: color-mix(in srgb, var(--accent) 12%, var(--surface-1));
  --tint-ground: transparent;
}
/* a fresh row set fades in staggered (sort and page changes); opacity only,
   a transform on a table-row is one engine's bug. The page's render pass
   re-adds the class to restart it. */
.table-wrap tbody.rows-in tr {
  animation: panel-in-fade var(--dur-near) var(--ease-out) both;
}
.table-wrap tbody.rows-in tr:nth-child(2) { animation-delay: calc(var(--stagger) * 1); }
.table-wrap tbody.rows-in tr:nth-child(3) { animation-delay: calc(var(--stagger) * 2); }
.table-wrap tbody.rows-in tr:nth-child(4) { animation-delay: calc(var(--stagger) * 3); }
.table-wrap tbody.rows-in tr:nth-child(5) { animation-delay: calc(var(--stagger) * 4); }
.table-wrap tbody.rows-in tr:nth-child(6) { animation-delay: calc(var(--stagger) * 5); }

/* a secondary text cell: the preview's inline-styled region column, as a
   class. 5.47:1 on surface-1 dark, 5.89:1 light; on the selection tints
   5.38:1 and 5.02:1 */
.table-wrap .td-sub { color: var(--text-3); }

/* the checkbox column: one checkbox wide, never a full text column */
.table-wrap .cell-select { width: 1%; white-space: nowrap; padding-inline: var(--space-3); }
.table-wrap .cell-select .checkbox-label { justify-content: center; }

/* the row is the target: anywhere on a selection row toggles it, the JS
   guard skips clicks that landed on another control. Text does not select,
   so a click cannot double as a drag. A row whose box is disabled is not a
   target, and keeps no pointer. */
.table-wrap tbody tr:has(.cell-select) { user-select: none; }
.table-wrap tbody tr:has(.cell-select input:not(:disabled)) { cursor: pointer; }

/* ---- Pagination ----
   The count line states the slice; the strip scrolls inside itself, so at
   390px every page button stays reachable while the count and the prev and
   next buttons hold their place. */
.pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  flex-wrap: wrap;
  margin-top: var(--space-4);
}
.pagination-count {
  font: 400 var(--t-2)/1.4 var(--font-sans);
  font-variant-numeric: tabular-nums;
  color: var(--text-3);   /* 6.34:1 dark, 5.26:1 light on --bg */
}
.pagination-pages {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  overflow-x: auto;
}
/* the current page reuses the outlined-primary pair: accent-text on the
   quiet fill, 6.70:1 dark over --bg; light takes the 70/30 mix, 4.92:1 */
.pagination .btn[aria-current="page"] {
  background: var(--accent-quiet);
  color: var(--accent-text);
  border-color: var(--accent-line);
  cursor: default;
}

/* ---- Filter bar ----
   A search input plus the active filters as chips in the .tag language;
   removing a chip removes the filter. Nothing here is a new control. */
.filter-bar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-bottom: var(--space-4);
}
.filter-search { flex: 1 1 220px; max-width: 320px; }
/* chips pop in and fade out instead of appearing and vanishing; the exit
   is a .closing costume the controller holds before removal */
.filter-chip { padding-inline-end: 4px; animation: chip-in var(--dur-near) var(--ease-out) both; }
.filter-chip.closing { animation: chip-out var(--dur-out-near) var(--ease) both; }
@keyframes chip-in { from { opacity: 0; transform: scale(0.85); } }
@keyframes chip-out { to { opacity: 0; transform: scale(0.85); } }
/* The chip's × is the one remove at chip scale: the skeleton, the glyph box and
   the remove hover all come from `.close`, and these three override only the
   parts that would break the pill. The scope is the chip, because the bare
   `.close` rules further down this file would otherwise grow the × to the 30px
   square; one (0,2,0) selector covers both shapes, since the svg form wears
   `.close` beside `.icon` and outranks the shared `.icon` box either way. The
   pill row carries the 44px floor, so the glyph keeps its own box.
   The hover restates the remove costume only because the chip's own ground
   moves: the fill lifts with the select's --lift, and the well reads 1.28:1
   dark / 1.23 light on the resting chip, 1.51 dark on the lifted one and
   1.03 light there, one of the three light grounds no grey clears 1.2 on
   (the record lives with the remove skeleton rules). */
.filter-chip .close {
  width: 16px;
  height: 16px;
  border-radius: var(--r-pill);
  color: inherit;
  transition: background var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease);
}
.filter-chip .close:hover { color: var(--danger); background: var(--well); --tint-ground: transparent; }
.filter-chip .close svg { width: 8px; height: 8px; }

/* The picker: a filter button plus a popover of checkbox rows anchored to
   the bar, so filters can be added back as well as removed. The rows are
   .menu-item.checkbox-label: one target, the system's own box. Closes on
   outside click and Escape; the exit is the popover's missing one. */
.filter-menu-wrap { position: relative; }
/* A trigger on the bar's end edge opens its menu off the end of the wrapper,
   since the panel is wider than the button it hangs from. Same edge
   `.btn-split-menu` anchors to by default. */
.filter-menu.menu-end { left: auto; right: 0; }
/* the checkbox already pads its row visually, so the row gives 2px back: above
   the first box the panel's gutter plus the row's own 8px measures a 15.84px
   strip of dead panel, against 13.84 at 6 */
.filter-menu .menu-item { padding-block: 6px; }
/* Late reduce block: the rules below this point sit after the main one, so
   their restatements have to as well, or the later base rule wins by order. */
@media (prefers-reduced-motion: reduce) {
  /* rows and chips appear and go instantly; the chip's removal timer still holds it */
  .table-wrap tbody.rows-in tr { animation: none; }
  .filter-chip { animation-name: panel-in-fade; }
  .filter-chip.closing { animation-name: fade-out; }
}

/* ---- Empty result inside the table ----
   The shipped .empty-state, seated in a borderless cell so the table's own
   padding does not double it. */
.table-wrap tr.table-empty td { padding: 0; }

/* ---- Coarse pointers: the row carries the tap floor ----
   Buttons and chips keep their proportions; the sort header and each chip
   grow to the floor like menu rows do. */
@media (pointer: coarse) {
  .th-sort { min-height: var(--ctl-lg); }
  /* the start side only: padding-inline would reset the 4px end that makes
     room for the chip's × */
  .filter-chip { min-height: var(--ctl-lg); padding-inline-start: 10px; }
  /* chip scale, not the bare control's floor: the pill row already carries it,
     and this (0,2,0) scope holds both shapes below the coarse `.close` box */
  .filter-chip .close { width: 22px; height: 22px; }
}

/* ---- Navbar narrow icon state ----
   Viewport-driven, so it swaps in one frame: no animation, by design.
   Under the navbar's own 768px boundary, where the search is already off
   and the links scroll, links that carry an icon fold to it and the brand
   keeps its mark while the wordmark hides. */

@media (max-width: 768px) {
  .navbar { padding-inline: var(--space-4); }

  .navbar .logo-name { display: none; }
  .navbar-brand { padding-right: var(--space-3); margin-right: var(--space-2); }

  /* only links that carry an icon fold to it: a text-only link keeps its
     label, or the narrow navbar shows nothing for it. The label goes
     sr-only rather than display:none, because display:none would take the
     accessible name with it. */
  .navbar-link:has(> svg) .navbar-link-text {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
  .navbar-link:has(> svg) { padding-inline: var(--space-3); }

  /* badges stay: a count on a folded link is still the count */
}

/* ---- Rhythm: bands, measure, page header, long-form ----
   Six pieces that break a page out of one column of identical cards on one
   tone. A band changes the tone under a whole section; the narrow column stops
   a paragraph running the width of a dashboard; the page header is the level
   above .section-head; .prose is the long-form block; the placeholder holds an
   asset's space; the card takes a density.

   Ratios below were measured with tools/contrast.mjs against the declared
   tokens; the character counts were measured in the browser against loaded
   Onest, not derived from `ch` (the width of "0" is not the average advance in
   a proportional face).

   Nothing here animates except one underline thickness on hover, which is a
   paint change and stays under reduced motion, so this block restates nothing
   in the reduced-motion section. */

/* ---- Band ----
   A section that changes the tone under the whole viewport while its content
   stays on the page column. Same three declarations .section-sticky uses for
   its stuck state, and for the same reason: --vp-w is the scrollbar-excluded
   viewport width the controller writes, and 100vw counts a classic scrollbar
   and hangs a horizontal overflow off the page. The fallback is there for
   parity, not as a plan.

   Two properties of that geometry, both load-bearing:
   - it needs the page column CENTRED in the viewport. In a sidebar shell the
     column is offset and a band would span the sidebar too, so a band belongs
     to the navbar shell.
   - it is inert when the band is already full width: a band as a direct child
     of body resolves margin-inline and padding-inline to 0, so the same class
     works with a .page nested inside it.

   Tone steps, measured against --bg: the well band 1.110:1 dark and 1.095:1
   light, the raised band 1.156:1 and 1.124:1. The raised step is exactly the
   card-on-page step the eye already reads. No shadow either way: elevation is
   for floating objects, and a band is a plane. */
/* The stuck section header bleeds by the same three declarations and for the
   same reason, so it takes them from here instead of restating them. */
.band,
.section-sticky.is-stuck {
  width: var(--vp-w, 100vw);
  margin-inline: calc(50% - var(--vp-w, 100vw) / 2);
  padding-inline: calc((var(--vp-w, 100vw) - 100%) / 2);
}
.band { padding-block: var(--space-16); }
@media (max-width: 768px) { .band { padding-block: var(--space-10); } }

/* The well sinks, so the tone step is its only edge: --lit-* are the raised
   edges and there is no sunken one to reach for. */
.band-well { background: var(--well); --tint-ground: transparent; }
/* The raised band rises, so it takes the lit edge, which flips to the
   underside in Latte because that is where a light page shows a raised plane. */
.band-raised { background: var(--surface-1); --tint-ground: transparent; box-shadow: var(--lit-1); }
/* The accent band is the closing ask's ground, one per page: the sapphire turned
   up where a product page ends. It takes no edge, the tone is the step, and every
   text level and the filled action clear their floors on it (tokens.css). */
.band-accent { background: var(--band-accent); --tint-ground: transparent; }

/* Two bands of the same tone are one band, so the seam keeps one section gap
   instead of stacking two paddings into a dead strip. Two bands of DIFFERENT
   tones need no rule: the step between them is the edge, and a gap of page
   tone between them would read as a third band.

   THE STACK'S OWN MARGIN GOES WITH THE PADDING, and dropping the padding alone
   is what made the seam worse rather than better: a section stack puts its gap
   between the two bands, so zeroing the padding left a strip of PAGE tone
   standing between two identical grounds - the third band this rule exists to
   prevent - with the second band's content flush against its top edge (measured
   64px on rhythm's --space-16 stack). With the margin gone the seam closes and
   the first band's own block-end padding is the one section gap.

   A parent's `gap` is not a margin and no rule on the child can cancel one, so a
   stack that spaces its sections with `gap` reopens the strip. Stack bands with
   margins, or with nothing between them. */
.band-well + .band-well,
.band-raised + .band-raised {
  margin-block-start: 0;
  padding-block-start: 0;
}

/* ---- Narrow column ----
   Opts a section into the prose measure and centres it in the page column. A
   .prose block caps itself; this is for a section that mixes prose with a
   .section-head, a callout or a form and wants all of it on one measure. */
.section-narrow {
  max-width: var(--page-narrow);
  margin-inline: auto;
}

/* ---- Page header ----
   The level above .section-head: what screen this is, where it sits, and what
   can be done to it. Breadcrumb on its own line, then the title block and the
   actions on one row.

   The wrap threshold is the prose measure, not a round number: the title block
   holds one measure or the actions take their own line. Where that lands is a
   function of the toolbar, which the CSS cannot know; for the two-control
   toolbar on the demo page it measured 744px of viewport. */
.page-header {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  column-gap: var(--space-5);
  row-gap: var(--space-4);
  margin-bottom: var(--space-10);
}
.page-header > .breadcrumb { flex: 1 0 100%; }
.page-header-body { flex: 1 1 var(--page-narrow); min-width: 0; }
/* 0 0 auto cannot shrink below its content's max-content width, so a toolbar
   of buttons never wraps and the overflow lands on the document instead:
   measured 322px inside a 288px column at 320. The toolbar's own flex-wrap is
   what should decide, so let it shrink. */
.page-header > .toolbar { flex: 0 1 auto; }

/* --t-9, one step above .section-title's --t-8, so a page and a section inside
   it are never the same size. Tracking tightens with size, since Onest's
   default spacing opens up past 40px. overflow-wrap because a title is
   content: an unbroken machine string in one would otherwise widen the page. */
.page-title {
  font: 600 var(--t-9)/1 var(--font-sans);
  letter-spacing: -0.045em;
  color: var(--text);
  overflow-wrap: anywhere;
}
@media (max-width: 768px) { .page-title { font-size: var(--t-8); } }

/* Metadata reads under the title, never beside it: it is the smallest type on
   the screen and a 64px title beside 12px text has no shared baseline worth
   aligning. --text-3 measures 6.35:1 dark and 5.27:1 light on --bg. The 20px
   column gap is the separator; a middot pseudo lands at a line end or a line
   start the moment the row wraps. */
.page-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-5);
  margin-top: var(--space-3);
  font-size: var(--t-2);
  color: var(--text-3);
}
.page-meta-item {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}
/* Undimmed: --icon-dim is for an icon beside its own label in a nav row, and
   this row is already the smallest type on the page. */
.page-meta-item > svg { width: 14px; height: 14px; flex-shrink: 0; }

/* ---- Prose ----
   The long-form block: 16px body on the measure, headings on the scale's own
   steps, and every gap from the spacing scale. Body is --text-2 and headings
   are --text, the same split .table-wrap and pre already use, so a document
   and a data view read at one volume.

   Measured on the three surfaces prose sits on, --bg / --well / --surface-1:
   body 8.43 / 9.36 / 7.30 dark and 7.44 / 6.79 / 8.36 light; headings 11.70 /
   12.98 / 10.12 and 11.98 / 10.95 / 13.47; the quote and caption step 6.35 /
   7.05 / 5.49 and 5.27 / 4.82 / 5.92; the list marker 5.47 / 6.07 / 4.73 and
   4.94 / 4.52 / 5.55. Worst pair in the set is 4.52:1.

   1.65 rather than the body's 1.55: that number is set for 14px UI rows, and
   this runs 16px at 68 characters. */
.prose {
  max-width: var(--page-narrow);
  font-size: var(--t-4);
  line-height: 1.65;
  color: var(--text-2);
}

/* One gap between flow siblings, and the same gap inside the three containers
   that hold flow content of their own. Descendant `* + *` is not used: .tag is
   inline-flex, so an atomic inline-level box in a sentence would take the
   margin and shift the line. */
.prose > * + *,
.prose blockquote > * + *,
.prose li > * + *,
.prose figure > * + * { margin-block-start: var(--space-4); }

/* Headings keep the sizes tokens.css already gives h1-h4; only the rhythm is
   set here. The space above a heading belongs to the heading, the space below
   it is short, because a heading and its first paragraph are one object. */
.prose h2 { margin-block-start: var(--space-10); }
.prose h3 { margin-block-start: var(--space-8); }
.prose :is(h4, h5, h6) { margin-block-start: var(--space-6); }
/* The scale has four heading steps. h5 and h6 fold onto h4 rather than
   inventing a fifth: a document needing six levels needs splitting. */
.prose :is(h5, h6) { font: 500 var(--t-4)/1.3 var(--font-sans); color: var(--text); }
/* Later than the four rules above, so a heading following a heading takes the
   short gap rather than the long one. */
.prose :is(h2, h3, h4, h5, h6) + * { margin-block-start: var(--space-3); }

.prose :is(ul, ol) { padding-inline-start: var(--space-5); }
.prose li::marker { color: var(--text-4); }
/* the second selector catches a nested list that is an item's first child, which
   `* + *` cannot see. Both are (0,1,2), so the list gives each one its own match. */
.prose li + li,
.prose li > :is(ul, ol) { margin-block-start: var(--space-2); }
/* Nested ordered lists change type; nested unordered ones already do. */
.prose ol ol { list-style-type: lower-alpha; }
.prose ol ol ol { list-style-type: lower-roman; }

/* The rule is --text-4 because it is the only thing marking the quote: at the
   3:1 graphics floor --surface-3 measures 1.67 / 1.86 / 1.45 dark and 1.16 /
   1.06 / 1.31 light on the three surfaces and fails all six. --text-4 measures
   5.47 / 6.07 / 4.73 and 4.94 / 4.52 / 5.55. No fill and no radius: a tinted
   rounded box with a coloured left edge is the callout, and this is not one. */
.prose blockquote {
  padding-inline-start: var(--space-5);
  border-inline-start: 2px solid var(--text-4);
  color: var(--text-3);
}

/* An asset arrives at its own size, so its cell sizes it. The prose image and
   the hero shot agree on everything except that sizing, and the split is on
   purpose: prose CAPS an image at the measure, a hero shot FILLS its cell.
   Unifying `max-width` and `width` moves pixels in both directions. */
.prose img,
.hero-media img {
  display: block;
  height: auto;
  border-radius: var(--r-2);
}
.prose img { max-width: 100%; }
.prose figcaption {
  margin-block-start: var(--space-3);
  font-size: var(--t-2);
  color: var(--text-3);
}

/* 12px code inside 16px prose reads as a footnote, so inline code takes --t-3.
   `pre code` has to be restated: its own `font-size: inherit` is (0,0,2) and
   loses to the (0,1,1) rule above it, which would size every block of code at
   14px from inside one container class. */
.prose code { font-size: var(--t-3); }
.prose pre code { font-size: inherit; }

/* The tint carries the mark and the text lifts a step, the same construction
   .diff-add uses. --text over the 22% tint measures 7.49 / 8.36 / 6.51 dark
   and 9.02 / 8.33 / 9.95 light on the three surfaces; the tint's own step off
   the surface is 1.56:1 dark and 1.33:1 light, against the 1.156 / 1.124 of a
   card on the page, so the highlight reads as a stronger plane change than the
   one the eye already tracks. */
.prose mark {
  background: color-mix(in srgb, var(--accent) 22%, var(--tint-ground));
  color: var(--text);
  border-radius: 2px;
  padding: 0 1px;
}

/* An underline at rest, because the colour alone cannot carry it: --accent-text
   measures 1.08:1 against the body's --text-2 dark and 1.47:1 light, against
   the 3:1 that would let colour stand alone. Restraint is in the
   THICKNESS, not in the alpha: --accent-line as the decoration colour measures
   2.25 / 2.28 / 2.16 dark and 1.56 / 1.53 / 1.61 light against the surfaces,
   under the 3:1 floor everywhere, so it draws a line nobody can see. At
   currentColor the same hairline measures 9.15 / 10.15 / 7.91 and 5.06 / 4.62
   / 5.69. */
.prose a {
  color: var(--accent-text);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
  transition: text-decoration-thickness var(--dur-tint) var(--ease);
}
.prose a:hover { text-decoration-thickness: 2px; }

.prose hr { margin-block: var(--space-8); }
/* The block's own first and last gaps belong to whatever holds it. */
.prose > :first-child { margin-block-start: 0; }
.prose > :last-child { margin-block-end: 0; }

/* ---- Image placeholder ----
   Where a real asset has not arrived. A striped ground and a monospace label
   saying what belongs there, never a hand-drawn illustration standing in for
   a photograph.

   The stripe is a repeating hard-stop pattern, not a gradient: both stops of
   every pair carry one colour, so nothing interpolates and no blend is drawn.
   It is --text-4 at 18% rather than a neutral surface token because the
   neutral steps sit on top of --well in Latte: --line-soft measures 1.33:1
   against the well in Mocha and 1.02:1 in Latte, which is no stripe at all.
   The mix measures 1.33:1 dark and 1.25:1 light, above the 1.110 / 1.095 of a
   well band against the page, so it reads as texture in both.

   No border and no shadow: the ground is a well, and a well neither floats nor
   takes the lit edge. On a .band-well the ground matches the band and the
   stripes alone bound the box, which is the correct reading. A hole, not a card. */
.img-placeholder {
  aspect-ratio: var(--img-ratio, 16 / 9);
  display: grid;
  place-items: center;
  border-radius: var(--r-2);
  background-color: var(--well);
  --tint-ground: transparent;
  background-image: repeating-linear-gradient(
    -45deg,
    color-mix(in srgb, var(--text-4) 18%, var(--tint-ground)) 0 1px,
    transparent 1px 9px
  );
}
/* Mono, as typed, and sentence case: the tracked caps stay on their allowlist.
   --text-3 on --surface-1 measures 5.49:1 dark and 5.92:1 light. */
.img-placeholder-label {
  font: 400 var(--t-1)/1 var(--font-mono);
  color: var(--text-3);
  background: var(--surface-1);
  --tint-ground: transparent;
  padding: var(--space-2) var(--space-3);
  border-radius: var(--r-1);
}

/* ---- Card density ----
   One padding value made a data panel and a marketing card the same object.
   Three steps off the spacing scale, 12 / 20 / 32, far enough apart to be a
   choice; 16 / 20 / 24 is not worth three class names.

   Written one class longer than .card so the pair cannot be decided by source
   order wherever this block lands in the file.

   Where each belongs: tight on --bg and inside a .band-well, where a card
   carries rows, chips or numbers and there are eight of them. Roomy inside a
   .band-well or on --bg where the card IS the section. Neither belongs on a
   .band-raised: the card's fill and the band's are the same colour, so only
   the drawn line separates them, and a raised band is for type,
   not for cards. A
   card on a well band measures 1.283:1 dark and 1.230:1 light against its
   ground, the strongest tone step the system has. */
.card.card-tight { padding: var(--space-3); }
.card.card-roomy { padding: var(--space-8); }
/* At 390px a roomy card spends 64 of 358 available pixels on air. */
@media (max-width: 768px) { .card.card-roomy { padding: var(--space-5); } }
/* A card holding setting rows drops its INLINE padding for the rows, and every
   other child keeps the card's 20px gutter on its own: a header's title sits
   20px in from the card's left, a trailing save row 20px in from its right,
   and the card's block padding still holds above a title and under a save
   row. Her call 2026-09-05, put to her as "rows flush, the rest keeps the
   gutter". Only an edge row sits on the card's edge, so the hover fill
   reaches the edge the row touches and nothing else does.

   A card holding NOTHING but rows is fully flush rather than inline-flush:
   the row's own 20px IS the gutter, its fill reaches both card edges and the
   edge rows' --r-2 corners are the card's own. Her call 2026-09-04, "the
   whole row should light up, without leaving empty space". At (0,3,0) that
   clause outranks `.card-tight` and `.card-roomy` on specificity rather than
   on where it sits.

   Both clauses ask what PAINTS, so a `<template>` a page clones its rows
   from does not count as a child: it draws nothing, so nothing of it can meet
   the card's edge, and a rule that turned on an invisible element would be a
   defect nobody could see to diagnose. */
.card:has(> .setting-row) { padding-inline: 0; }
/* `.section-sticky` is excluded because it is full-bleed chrome rather than
   a gutter child: a stuck header spans the viewport and derives its own
   inline padding from the page column, so a 20px flat here would pull its
   toolbar off the column the first time the card scrolled. */
.card:has(> .setting-row) > :not(.setting-row, .section-sticky, template, script, style) {
  padding-inline: var(--space-5);
}
.card:has(> .setting-row):not(:has(> :not(.setting-row, template, script, style))) { padding: 0; }

/* ---- Charts ----
   Inline SVG, no library, no canvas. The shell is HTML and only the marks are
   SVG, because the two need different coordinate systems: a chart stretches to
   its column while its type stays on the ladder, and one stretched viewBox
   cannot do both. So `.chart-svg` takes preserveAspectRatio="none" and every
   stroke in it takes vector-effect: non-scaling-stroke (the scaled-stroke trap
   .stat-spark already names), while every label lives in the HTML gutters
   around it and is never scaled at all.

   Three things a stretched viewBox breaks, all measured in Chrome 141:

   1. A <circle> becomes an ellipse. r="4" in a 100-unit box painted 600px wide
      measured 48 x 8. Point markers are therefore ZERO-LENGTH LINES with a
      round cap and a non-scaling stroke: the cap paints a true circle whose
      diameter is the stroke width, in css px, at any stretch. A square cap
      gives a square marker the same way.
   2. pathLength normalisation stops working. `pathLength="1"` plus
      `stroke-dasharray: 1` draws a solid line in a stretched viewBox, and the
      same markup with non-scaling-stroke breaks into a repeating dash whose
      period is the USER-space path length (measured: a 165-unit path painted
      ~700px long, dashes every ~165px). The dash-based line draw-on is
      therefore not available here, and the entrance is a scale from the axis
      instead. The ring, whose viewBox is uniform and whose stroke scales, is
      the one place pathLength does work, so the ring needs no circumference
      constant.
   3. A rounded corner distorts with everything else, so bars are square.

   The four series colours are the tokens, measured on --surface-1 for WCAG
   1.4.11's 3:1 graphics floor: 5.21 / 5.27 / 6.62 / 6.45:1 dark and
   5.81 / 5.80 / 5.12 / 5.55:1 light; on --bg, 6.02 / 6.09 / 7.65 / 7.46 dark
   and 5.17 / 5.16 / 4.56 / 4.94 light. Four is the ceiling: a fifth series is
   a legend problem, and yellow and red stay out because they read as status.

   Gridlines are scaffolding, not graphical objects: the tick labels carry the
   values, so 1.4.11 does not bind them. They still have to be visible, and the
   two hairline tokens are not, on the surface a chart actually sits on:
   --line-soft measures 1.04:1 on --surface-1 dark and --line 1.18:1, both of
   which painted nothing on screen. --surface-3, which is already the system's
   border on .btn-secondary and .input:hover, measures 1.45:1 dark and 1.31:1
   light on --surface-1, and 1.67 / 1.16 on --bg.

   The AXIS is not a divider, it is the reading furniture the tick labels
   belong to, so it takes their own colour: --text-4 at 4.73:1 dark and 5.55:1
   light on --surface-1, 5.47 / 4.94 on --bg. That is also the only value at
   which the 4px tick marks are visible at all. Both are exposed as
   --chart-grid and --chart-axis. */

.chart {
  /* the four slots a page picks from, plus the knobs. --series is the one
     colour every mark reads, so a slot class is all a series needs. */
  --series: var(--chart-series-1);
  --chart-grid: var(--surface-3);   /* 1.45:1 on --surface-1 dark, 1.31 light */
  --chart-axis: var(--text-4);     /* 4.73:1 dark, 5.55 light on --surface-1 */
  --chart-aspect: 16 / 6;

  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr);
  grid-template-areas:
    "ytitle yticks plot"
    ".      .      xticks"
    ".      .      xtitle";
  /* the tick count is a function of the CHART's width, not the viewport's:
     the same chart is wide on a dashboard and narrow in a sidebar */
  container-type: inline-size;
}

/* A tint that reads as a fill without competing with its own line: 1.30:1 over
   --surface-1 dark, 1.26:1 light, and two stacked land 1.63 and 1.58. A
   sparkline is the other ground an area lands on, so it carries the knob too. */
.chart, .stat-spark { --chart-area-alpha: 0.16; }

/* Each part carries its own gutter rather than the grid carrying a gap: an
   absent axis title then costs nothing, where a gap would leave its lane. */
.chart-plot {
  grid-area: plot;
  position: relative;   /* the crosshair, the plate and .chart-empty all seat here */
  aspect-ratio: var(--chart-aspect);
  /* the axis lines ARE the plot's borders: a stroke drawn on the viewBox edge
     is centred on it and loses half its width to the clip */
  border-left: 1px solid var(--chart-axis);
  border-bottom: 1px solid var(--chart-axis);
}

.chart-svg {
  width: 100%;
  height: 100%;
  display: block;
  /* a marker sitting on the first or last x value is centred on the viewBox
     edge; the root svg clips at that edge unless this says otherwise */
  overflow: visible;
}

/* VECTOR-EFFECT DOES NOT INHERIT. stroke, stroke-width and shape-rendering all
   do, so a <g> handing its children a 1px non-scaling stroke actually hands
   them a 1px SCALED one: on a 16/6 plot that is a 4px gridline, and on a point
   marker it is the ellipse again. Every group form here names its children. */
.chart-grid,
.chart-grid line {
  fill: none;
  stroke: var(--chart-grid);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  /* a gridline lands on a fractional device row after the stretch and would
     paint as two half-lit rows; the grid is the one part with no curve to
     alias, so it can be snapped */
  shape-rendering: crispEdges;
}
/* dashed, so the fills under the lines carry the plot and the rules recede */
.chart-grid line { stroke-dasharray: 2 5; }

/* ---- Ticks ----
   Mono and tabular: digits line up down the y column and across a plate, and a
   tick label is a numeric readout, which is never the sans face. One fixed
   advance is also what makes the tick pitch below a single number rather than
   a per-label measurement. */
.chart-tick {
  font: 400 var(--t-1)/1 var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
  color: var(--text-4);   /* 4.73:1 on --surface-1 dark, 5.55 light; 5.47 / 4.94 on --bg */
  white-space: nowrap;
}

/* The y column stretches to the plot and then hangs half a label box past each
   end, so space-between lands the label CENTRES on 0% and 100% instead of the
   label boxes. Stretch resolves height as area minus margins, so the negative
   margin buys exactly one label box: measured centres 0 / 30 / 60 / 90 / 120
   on a 120px plot, against 5.5 / 27.75 / 50 / 72.25 / 94.5 without it. */
.chart-ticks-y {
  grid-area: yticks;
  align-self: stretch;
  margin-block: calc(var(--t-1) / -2);
  padding-inline-end: var(--space-2);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-end;
  text-align: right;
}
/* the tick mark, on the axis side of the label */
.chart-ticks-y .chart-tick { position: relative; padding-inline-end: 7px; }
.chart-ticks-y .chart-tick::after {
  content: '';
  position: absolute;
  inset-inline-end: 0;
  inset-block-start: 50%;
  width: 4px;
  border-top: 1px solid var(--chart-axis);
}

/* An x tick carries its own position, because the label widths are equal but
   the SPACING is not free: space-between puts centre i at i*(W-w)/(n-1)+w/2,
   which is off the gridline by w*(1/2 - i/(n-1)) everywhere but the middle -
   8px on a five-tick 600px axis. The ends pull in instead of overhanging, the
   convention every axis uses, so nothing overflows the plot. */
.chart-ticks-x {
  grid-area: xticks;
  position: relative;
  padding-block-start: var(--space-2);
  /* the row has no flow content, so it takes its height from the labels */
  min-height: calc(var(--t-1) + var(--space-2));
}
.chart-ticks-x .chart-tick {
  position: absolute;
  inset-block-start: var(--space-2);
  inset-inline-start: var(--at, 0%);
  translate: -50% 0;
}
.chart-ticks-x .chart-tick:first-child { translate: 0 0; }
.chart-ticks-x .chart-tick:last-child { translate: -100% 0; }

/* The band variant: one label per column, centred, for bars. Bands sit at
   (i + 0.5)/n, never at i/(n-1), so this is a different layout and not a
   spacing tweak of the one above. */
.chart-ticks-x.chart-ticks-band {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
}
/* truncates on tokens.css's .truncate, which names this selector */
.chart-ticks-band .chart-tick {
  position: static;
  translate: none;
  text-align: center;
}

/* One label per 64px of plot width keeps a 5-character mono tick clear of its
   neighbour: the label measures 33px at --t-1 and two of them need --space-4
   between to read as two values. Ship an odd count so halving keeps both ends;
   under 480px of chart the CSS drops every second one, and because each tick
   holds its own --at the survivors stay exactly on their gridlines. */
@container (max-width: 480px) {
  .chart-ticks-x:has(.chart-tick:nth-child(5)) .chart-tick:nth-child(even) { display: none; }
}

.chart-axis-title {
  grid-area: xtitle;
  padding-block-start: var(--space-1);
  font: 400 var(--t-2)/1.4 var(--font-sans);
  color: var(--text-3);   /* 5.49:1 on --surface-1 dark, 5.92 light */
  text-align: center;
}
/* vertical-rl reads top-to-bottom; the half turn is what makes an axis title
   read bottom-to-top, which is the direction the axis counts in */
.chart-axis-title-y {
  grid-area: ytitle;
  padding: 0 var(--space-1) 0 0;
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  text-align: center;
}

/* ---- Series marks ----
   One class per slot, and every mark inside reads --series. */
.chart-series-1 { --series: var(--chart-series-1); }
.chart-series-2 { --series: var(--chart-series-2); }
.chart-series-3 { --series: var(--chart-series-3); }
.chart-series-4 { --series: var(--chart-series-4); }

.chart-line {
  fill: none;
  stroke: var(--series);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* A flat tint, never a gradient, and never on its own: the fill sits far under
   the 3:1 floor by design, and the 2px line on top is what carries the value.
   ui.js builds it under every line and every sparkline, off the line's own
   points, so no page writes one. */
.chart-area {
  fill: var(--series);
  fill-opacity: var(--chart-area-alpha);
  stroke: none;
}

/* The zero-length-line marker. stroke-width IS the diameter, in css px. The
   group and its lines both, for the reason above. */
.chart-point,
.chart-point line {
  stroke: var(--series);
  stroke-width: 7;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
  fill: none;
}
/* the readout's mark. No transition: it does not travel, it changes which
   point is it, and a growing dot that lags the crosshair reads as a bug */
.chart-point.is-active,
.chart-point line.is-active { stroke-width: 11; }
/* ui.js marks every line's last point with `.chart-end`; a sparkline's is
   scaled to its 1.5px line */
.stat-spark .chart-point { stroke-width: 6; }

/* Bars are fills, so they scale with the plot the way the data does and need
   no vector-effect. Square: a corner radius in a stretched viewBox turns into
   an ellipse quadrant. */
.chart-bar { fill: var(--series); }

/* ---- The data entrance ----
   Every mark grows out of the x axis together. transform-box: view-box makes
   the origin the plot's own bottom edge rather than each mark's bounding box,
   so the marks share one baseline; non-scaling-stroke means the growing line
   keeps its 2px and the markers keep their diameter throughout.
   The rejected alternative is the dash draw-on, which this viewBox cannot
   express (see the header). */
/* The hiding state sits behind `.will-draw`, a class JS adds at runtime, so a
   chart with no controller paints its data instead of nothing. Same contract
   [data-reveal] runs under, and for the same reason. */
.chart-series {
  transform-box: view-box;
  transform-origin: center bottom;
  transition:
    transform var(--dur-far) var(--ease-out),
    opacity var(--dur-far) var(--ease-out);
}
.chart.will-draw .chart-series { transform: scaleY(0); opacity: 0; }
.chart.will-draw.is-drawn .chart-series { transform: none; opacity: 1; }

/* Bars arrive one band at a time, so the group cannot be the thing that
   scales: a staggered child inside a scaling parent is two clocks on one move.
   The group keeps the fade and hands the geometry to the bars. */
.chart-series:has(.chart-bar) { transform: none; }
.chart-bar {
  transform-box: view-box;
  transform-origin: center bottom;
  transition: transform var(--dur-far) var(--ease-out);
}
.chart.will-draw .chart-bar { transform: scaleY(0); }
.chart.will-draw.is-drawn .chart-bar { transform: none; }
/* the system's stagger, capped at six, keyed on the band and not the series:
   in a grouped set every series' first bar leaves together */
.chart-bar:nth-child(2) { transition-delay: var(--stagger); }
.chart-bar:nth-child(3) { transition-delay: calc(var(--stagger) * 2); }
.chart-bar:nth-child(4) { transition-delay: calc(var(--stagger) * 3); }
.chart-bar:nth-child(5) { transition-delay: calc(var(--stagger) * 4); }
.chart-bar:nth-child(n + 6) { transition-delay: calc(var(--stagger) * 5); }

/* A series switched off at the legend. Not [hidden]: the mark fades where a
   display swap would pop, and it keeps its box so nothing else reflows.
   Restated under .chart.is-drawn, which is (0,3,0) and outranks a two-class
   off state: measured, the group kept opacity 1 with .is-off on it. */
.chart-series.is-off,
.chart.will-draw.is-drawn .chart-series.is-off {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-out) var(--ease);
}

/* ---- Hover readout ----
   The rule is html, not svg: a dash pattern is computed in the unstretched
   user space, so an svg crosshair would carry a different dash length on every
   chart width. --at is the one position vocabulary in this block; the ticks
   and the plate read it too. */
.chart-crosshair {
  position: absolute;
  inset-block: 0;
  inset-inline-start: var(--at, 0%);
  width: 0;
  border-left: 1px dashed var(--text-4);   /* 4.73:1 dark, 5.55 light on --surface-1 */
  pointer-events: none;
}

/* The plate is the shipped tooltip, moved in x and nothing else: .popover
   gives it the surface, the shadow and the glass, .tooltip gives it the
   padding, the entrance and the exit. The series swatches clear the 3:1 floor
   on the glass panel's worst case, so the material needs no re-floor here:
   3.34 / 3.38 / 4.52 / 4.13:1 dark and 4.53 / 4.62 / 3.57 / 4.41:1 light.
   Written three classes deep so it cannot be outranked wherever it lands.

   The centring is `translate`, never `transform`: .tooltip's entrance
   animates transform, and an animation replaces the property outright. As its
   own property the offset survives the keyframe - measured, the plate holds
   dead centre while the animation runs matrix(0.985, 0, 0, 0.985, 0, 3).

   It hangs inside the plot rather than above it, which is .tooltip's own
   placement: a plate over a chart's own header escapes the card, since a card
   does not clip. Both block insets are stated, so the plate does not depend on
   the .below class for its position - .below only picks the entrance that
   settles downward, which is the direction it arrives from here. */
.popover.tooltip.chart-plate {
  inset-block-start: var(--space-2);
  inset-block-end: auto;
  inset-inline-start: var(--at, 0%);
  translate: -50% 0;
  pointer-events: none;
}
.chart-plate-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  color: var(--text-2);   /* 4.68:1 on the dark glass worst case, 6.66 light */
}
.chart-plate-row + .chart-plate-row { margin-top: var(--space-1); }
.chart-plate-value {
  margin-inline-start: auto;
  padding-inline-start: var(--space-3);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
  color: var(--text);   /* 6.48:1 dark, 10.73 light on the same worst case */
}
/* the x value the row set belongs to */
.chart-plate-head {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--text);
  margin-bottom: var(--space-1);
}

/* ---- Loading ----
   The shipped .skeleton, in the shape of the thing arriving: a chart's
   placeholder is columns, because a flat grey block says nothing about what is
   coming. No shimmer, by spec. */
.chart-skeleton {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-end;
  gap: var(--space-2);
}
.chart-skeleton > .skeleton { flex: 1 1 0; }   /* 1.18:1 on --surface-1 dark, 1.19 light */

/* An empty or loading plot drops its scaffolding: gridlines drawn behind an
   overlay are furniture around nothing, and the axis lines stay because they
   are what says this is still a chart. */
.chart.is-empty .chart-grid,
.chart.is-loading .chart-grid,
.chart.is-empty .chart-ticks-y,
.chart.is-loading .chart-ticks-y,
.chart.is-empty .chart-ticks-x,
.chart.is-loading .chart-ticks-x { visibility: hidden; }

/* the error state seats the system's own danger surface in the plot, so a
   chart that failed uses the same panel as every other failure on the page.
   --danger on that tint measures 4.74:1 dark and 5.47:1 light. */
.chart-empty > .callout { text-align: start; max-width: 100%; }

/* ---- Legend ----
   .legend and .legend-item ship already; this adds the mark for a filled
   series and the interactive form. Both read --series from the same slot class
   the chart uses, so a legend cannot drift from the mark it names. */
.legend .legend-line { background: var(--series, currentColor); --tint-ground: transparent; }
.legend-swatch {
  /* one width with the line mark, so the two sit level in a legend row; a
     filled block needs less height than a stroke reads long */
  width: 12px;
  height: 8px;
  border-radius: calc(var(--r-1) * 0.3);
  flex-shrink: 0;
  background: var(--series, currentColor);
  --tint-ground: transparent;
}

/* The toggle: a real button, because a legend item that filters the chart is a
   control. aria-pressed carries the state, so the css never disagrees with
   what assistive tech reads. */
.legend-toggle {
  border: none;
  background: none;
  padding: 0;
  /* not the `font` shorthand: it resets font-size and beats .legend-item's
     --t-2 by source order, painting a toggle 14px beside 12px static items */
  font-family: inherit;
  font-weight: inherit;
  line-height: inherit;
  letter-spacing: inherit;
  color: var(--text-3);   /* 5.49:1 dark, 5.92 light on --surface-1 */
  cursor: pointer;
  transition: color var(--dur-tint) var(--ease);
}
.legend-toggle:hover { color: var(--text); }
.legend-toggle[aria-pressed="false"] { color: var(--text-4); }   /* 4.73:1 dark, 5.55 light */
.legend-toggle[aria-pressed="false"]:hover { color: var(--text-3); }   /* 5.49:1 dark, 5.92 light */
/* the mark hollows instead of greying: the series keeps its identity while it
   is switched off, and an inset ring changes no box dimension */
.legend-toggle[aria-pressed="false"] .legend-swatch,
.legend-toggle[aria-pressed="false"] .legend-line {
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--series, currentColor);
}

@media (pointer: coarse) {
  /* the row carries the floor, the swatch keeps its size */
  .legend-toggle { min-height: var(--ctl-lg); }
}

/* ---- Reduced motion ----
   The data still arrives, it just arrives in place: the fade stays and the
   geometry snaps. A media query adds no specificity, so each restatement is
   written at the specificity of the entrance rule it replaces. The hiding rule
   is `.chart.will-draw .chart-series`, three classes; a one-class restatement
   here would lose to it and the scale would come back. */
@media (prefers-reduced-motion: reduce) {
  .chart.will-draw .chart-series,
  .chart.will-draw.is-drawn .chart-series {
    transform: none;
    transition: opacity var(--dur-far) var(--ease);
  }
  .chart.will-draw .chart-bar,
  .chart.will-draw.is-drawn .chart-bar { transform: none; transition: none; }
  .chart-bar:nth-child(2),
  .chart-bar:nth-child(3),
  .chart-bar:nth-child(4),
  .chart-bar:nth-child(5),
  .chart-bar:nth-child(n + 6) { transition-delay: 0s; }
}

/* ---- Icons ----
   One drawn set, one box class, one tile. Pages stop inlining paths.

   The glyphs ship as a <symbol> sprite pasted into <body> as its first child;
   `<use href="#i-name"/>` instantiates one. INLINE IS THE CONTRACT, not
   `<use href="sprite.svg#i-name">`: cross-document <use> has never been
   dependable across engines, and an inline sprite costs one paste. Not
   measured here, no Safari on this box.

   The size ladder is one class deep, and every `svg` rule already in this file
   is a descendant selector one step stronger: `.btn svg` 14, `.btn-icon svg` 15,
   `.nav-item svg` 14, `.toast-icon svg` 16. So an icon dropped into a shipped
   component keeps THAT component's size, and `.icon` only supplies the paint
   and the flow. Nothing above had to change for this block to land.

   `vertical-align` is Onest's own cap-height midpoint: cap height measures
   0.714em (readings of 100/140, 142/200 and 287.5/400 px), so the centre sits
   0.357em over the baseline and the icon hangs half its own height below it. It bites only in running text; in
   a flex row (`.btn`, `.nav-item`, `.menu-item`) the line box centres it and
   this value is ignored.

   The stroke SCALES with the icon, by design: 1.5 units on a 16 viewBox paints
   1.31px at 14, 1.5px at 16, 1.88px at 20, so the set reads as one drawing
   resized. `vector-effect: non-scaling-stroke` is for a NON-uniform stretch
   only (`.stat-spark` is the shipped case). `.icon` drives both axes off one
   property, so an `.icon` cannot be stretched and never needs it.

   Corner treatment is `stroke-linejoin: round` and nothing else: every corner
   in the set takes the same 0.75-unit outer radius from the join, so no path
   carries an rx and no two icons can disagree about roundness. */

.icon {
  --icon-size: 16px;
  width: var(--icon-size);
  height: var(--icon-size);
  flex-shrink: 0;
  display: inline-block;
  vertical-align: calc(0.357em - var(--icon-size) / 2);
  /* The sprite carries these as presentation attributes too. Both layers earn
     their place: the attributes paint a bare `<use>` that carries no class, and
     these paint an `.icon` wrapping a raw path, which is how a 39th glyph gets
     tried before it joins the sprite. A CSS rule outranks a presentation
     attribute, so a filled path needs `style="fill:currentColor"` on itself. */
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* The rungs are px rather than tokens because they are icon BOXES, not type
   steps: 15 has no token at all, and every icon holder already in this file
   (.btn svg, .btn-icon svg, .toast-icon svg, .empty-state > svg) states its own
   the same way. */
.icon-sm  { --icon-size: 14px; }   /* beside 14px text, and in a .btn label */
.icon-ctl { --icon-size: 15px; }   /* the glyph that IS the control: .btn-icon, .hamburger */
.icon-lg  { --icon-size: 20px; }   /* a feature mark, standing alone */

/* ---- Icon tile ----
   The 32px soft-level square that heads a feature card. One colour drives the
   fill, the hairline and the glyph, so a variant is one line. 32 is the size
   SKILL.md names for it and the box .avatar already carries; no token holds it.

   The fill and the hairline are laid down OPAQUE, mixed into --surface-1 rather
   than tinted over whatever is behind, which is what keeps the tile off the
   double-composite trap: laid translucent on a glass panel the fill takes that
   panel's own backdrop a second time, and the accent-2 and danger glyphs
   measured 2.76:1 and 2.73:1 that way, under the 3:1 graphics floor.

   --tile-fill and --tile-line are --accent-quiet's and --accent-line's own
   alphas in both themes, so on a card the accent tile all but reproduces the
   token's own pixel: #354357 against #354356 dark, #DAEEF7 against #DEEDF6
   light. That light gap is four levels of red because color-mix keeps an
   out-of-sRGB intermediate while a composite paints the gamut-mapped colour
   first, and light --accent is outside sRGB.

   Glyph on its own fill, read off the painted pixel, 3:1 graphics floor:
     dark   accent 4.75  accent-2 4.07  success 5.38  warning 5.81  danger 4.05
     light  accent 3.91  accent-2 4.87  success 4.58  warning 4.96  danger 4.95
   The hairline carries no meaning, the fill and the glyph both carry the level,
   so it sits under that floor on purpose: 1.59:1 dark, 1.39:1 light on its fill. */
.icon-tile {
  --tile: var(--accent);
  --tile-fill: 16%;
  --tile-line: 38%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  flex-shrink: 0;
  border-radius: var(--r-2);
  background: color-mix(in srgb, var(--tile) var(--tile-fill), var(--surface-1));
  --tint-ground: transparent;
  border: 1px solid color-mix(in srgb, var(--tile) var(--tile-line), var(--surface-1));
  color: var(--tile);
}
[data-theme="light"] .icon-tile { --tile-fill: 12%; --tile-line: 34%; }
/* the tile sizes its own glyph, the way every other icon holder in this file
   does, so a `<use>` that carries no class cannot lay out at 300x150 */
.icon-tile > svg { width: 16px; height: 16px; }

/* A feature grid's default mark fills deeper and edges harder: the page's
   sapphire turned up where a product page has room for it. The semantic
   variants keep the shipped mix, so their own hue still reads as the level.
   Glyph on the deeper fill, read off the painted pixel as above: 4.00 dark,
   3.47 light; the hairline 2.05 and 1.72 on it. */
.feature > .icon-tile:not(.icon-tile-accent-2, .icon-tile-success, .icon-tile-warning, .icon-tile-danger) {
  --tile-fill: 24%;
  --tile-line: 60%;
}
[data-theme="light"] .feature > .icon-tile:not(.icon-tile-accent-2, .icon-tile-success, .icon-tile-warning, .icon-tile-danger) {
  --tile-fill: 20%;
  --tile-line: 55%;
}

.icon-tile-accent-2 { --tile: var(--accent-2); }
.icon-tile-success  { --tile: var(--success); }
.icon-tile-warning  { --tile: var(--warning); }
.icon-tile-danger   { --tile: var(--danger); }

/* ---- Timeline / activity feed ----
   Deploy history, audit log, comment thread: a vertical rail, one dot per
   entry, a mono timestamp, and a body that takes prose or a whole card.

   The rail is a translucent neutral, not --line. Measured on the two grounds a
   feed sits on: --line reads 1.37:1 on --bg and 1.18:1 on --surface-1 dark,
   1.22 / 1.37 light, so on a dark card it disappears and in light it is
   lopsided. --text-4 at 28 percent tracks whatever it sits on: 1.60 / 1.57
   dark, 1.45 / 1.47 light. No opaque token in either ramp holds above 1.4 on
   all four. The dots are the meaning, so they clear the 3:1 graphics floor:
   worst pair --text-4 on --surface-1 dark at 4.73:1, worst light --accent on
   --bg at 4.09:1.

   Colour alone never carries the outcome: the entry title says what happened
   and the dot is aria-hidden. */
/* THE list reset, for every semantic list this system dresses itself: a feed, a
   stepper, a tree, a footer column, a dropzone's files. Each is a real <ul>
   because it is a list; none of them wants a marker or the UA's block margins.
   `.tree-group`, `.tier-list` and `.form-summary-links` are NOT members: each
   carries a non-zero margin or padding of its own. */
.timeline,
.stepper,
.tree,
.footer-links,
.dropzone-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.timeline {
  /* the dot's box, and the lead that centres it on the title's first line:
     --t-3 at 1.35 is an 18.9px line box, so (18.9 - 10) / 2 rounds to 5 */
  --tl-dot: 10px;
  --tl-lead: 5px;
  display: flex;
  flex-direction: column;
}
.timeline-item,
.timeline-sep {
  position: relative;
  display: grid;
  grid-template-columns: var(--tl-dot) minmax(0, 1fr);
  column-gap: var(--space-3);
  align-items: start;
}
.timeline-item { padding-bottom: var(--space-5); }
.timeline-sep  { padding-bottom: var(--space-3); }

/* one rail segment per row, running the row's full height and passing behind
   the dot that covers it, so a day boundary does not break the thread */
.timeline-item::before,
.timeline-sep::before {
  content: '';
  position: absolute;
  left: calc((var(--tl-dot) - 2px) / 2);
  top: 0;
  bottom: 0;
  width: 2px;
  background: color-mix(in srgb, var(--text-4) 28%, var(--tint-ground));
}
/* the thread begins at the first dot and ends at the last: no stub above the
   first entry, none hanging below the last */
.timeline > :first-child::before { top: calc(var(--tl-lead) + var(--tl-dot) / 2); }
.timeline > :last-child::before  { bottom: calc(100% - var(--tl-lead) - var(--tl-dot) / 2); }
/* a leading date label has no dot to start from */
.timeline > .timeline-sep:first-child::before { display: none; }
.timeline > :last-child { padding-bottom: 0; }

.timeline-dot {
  width: var(--tl-dot);
  height: var(--tl-dot);
  margin-top: var(--tl-lead);
  border-radius: var(--r-pill);
  background: var(--text-4);   /* 5.47:1 dark and 4.94:1 light on --bg, 4.73 / 5.55 on --surface-1 */
  --tint-ground: transparent;
}
.timeline-dot-accent  { background: var(--accent); --tint-ground: transparent; }   /* 7.49 / 6.48 dark, 4.09 / 4.60 light */
.timeline-dot-success { background: var(--success); --tint-ground: transparent; }  /* 8.89 / 7.69 dark, 4.85 / 5.46 light */
.timeline-dot-warning { background: var(--warning); --tint-ground: transparent; }  /* 9.68 / 8.37 dark, 5.25 / 5.91 light */
.timeline-dot-danger  { background: var(--danger); --tint-ground: transparent; }   /* 5.98 / 5.18 dark, 5.36 / 6.02 light */

.timeline-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-2);
}
.timeline-title { overflow-wrap: anywhere; }
.timeline-time {
  font: 400 var(--t-1)/1.35 var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--text-3);   /* 6.35 / 5.49 dark, 5.27 / 5.92 light */
  white-space: nowrap;
}
.timeline-body {
  margin-top: var(--space-2);
  font-size: var(--t-2);
  color: var(--text-2);   /* 8.43 / 7.30 dark, 7.44 / 8.36 light */
  overflow-wrap: anywhere;
}
/* the date row: mono, because a date is data. Not the tracked-caps treatment,
   which stays on eyebrows, table headers and navigation group labels. */
.timeline-sep-date {
  grid-column: 2;
  font: 500 var(--t-2)/1.35 var(--font-mono);
  color: var(--text-3);
}

/* Load older: a hollow dot for the entry that has not arrived yet. The row
   centres its own dot against the button instead of measuring it, so the
   coarse-pointer height bump needs no restatement. */
.timeline-more { align-items: center; }
.timeline-more .timeline-dot {
  margin-top: 0;
  background: none;
  border: 2px solid color-mix(in srgb, var(--text-4) 28%, var(--tint-ground));
}
.timeline > .timeline-more:last-child::before { bottom: 50%; }

/* ---- Accordion ----
   A group of `.disclosure` rows on one card surface, with the open and close
   animated. A single row stays bare `.disclosure`: zero JS, instant, unchanged.

   One open at a time is markup, not CSS: give every `details` the same `name`
   and the browser closes its siblings. Many open at once is the same markup
   with the name left off.

   NAMED DEVIATION from transform-and-opacity. The panel animates block-size,
   because opening a row IS a layout change: everything below it moves down. A
   transform cannot express that. scaleY squashes the type and reflows nothing,
   and a translate leaves the rows below sitting on top of the panel.

   `interpolate-size` is what lets block-size leave `auto`, and it inherits, so
   one declaration here covers every row. `content-visibility` needs
   `allow-discrete` or the body blanks on frame one of the close and an empty
   box collapses. An engine without `::details-content` drops these rules
   whole and keeps the native instant open, which is the fallback. */
.accordion {
  interpolate-size: allow-keywords;
  background: var(--surface-1);
  --tint-ground: transparent;
  border-radius: var(--r-2);
  box-shadow: var(--elev-1);
  /* the first and last summary's hover fill hugs the card's own corners */
  overflow: hidden;
}
.accordion > .disclosure + .disclosure { border-top: 1px solid var(--line-soft); }
.accordion > .disclosure > summary {
  padding: var(--space-3) var(--space-5);
  border-radius: 0;
  overflow-wrap: anywhere;   /* a long token cannot push the chevron off the row */
}
.accordion > .disclosure > .disclosure-body {
  /* the body's ink sits centred between the label above it and the row's
     bottom edge: the summary's 12px under the label plus this 8px puts 20.8px
     of air above the text against the 21.6 below it, line-height slack
     included. Flush under the summary read top-heavy. */
  padding: var(--space-2) var(--space-5) var(--space-5);
  font-size: var(--t-2);
  color: var(--text-2);   /* 7.30:1 dark, 8.36:1 light on --surface-1 */
}
.accordion > .disclosure::details-content {
  block-size: 0;
  overflow: hidden;
  transition:
    block-size var(--dur-out-far) var(--ease),
    content-visibility var(--dur-out-far) var(--ease);
  transition-behavior: allow-discrete;
}
.accordion > .disclosure[open]::details-content {
  block-size: auto;
  transition:
    block-size var(--dur-far) var(--ease-out),
    content-visibility var(--dur-far) var(--ease-out);
  transition-behavior: allow-discrete;
}
/* `details` ships no disabled state, so ui.js cancels `click` on the summary
   of an aria-disabled row; keyboard activation goes through the same event, and
   the summary stays focusable so the row can announce why it cannot act. The
   costume is here so the row reads shut before anyone tries it. */
.accordion > .disclosure[aria-disabled="true"] > summary {
  color: var(--text-disabled);   /* 3.89:1 dark, 4.23:1 light on --surface-1 */
  cursor: not-allowed;
}
.accordion > .disclosure[aria-disabled="true"] > summary:hover {
  background: none;
  color: var(--text-disabled);
}
/* NO --icon-dim on top of the disabled colour: the two dims compound, and the
   chevron lands at 2.66:1 on --surface-1 dark, under the 3:1 graphics floor. It
   takes the row's own colour, which is already the dimmest thing on the card. */
.accordion > .disclosure[aria-disabled="true"] > summary svg { opacity: 1; }

/* ---- Stepper ----
   The header of a multi-step form: done, current, upcoming, and a connector
   carrying the progress. Horizontal by default, vertical on `.stepper-vertical`,
   labels folded away on `.stepper-compact`.

   Display only. A stepper reports where the form is; the form's own controls
   take the input and carry the disabled state, so nothing here is focusable
   and nothing here needs the coarse-pointer floor.

   The upcoming mark is `.input`'s costume, a --well disc inside a --line ring:
   1.11:1 dark and 1.09:1 light on --bg. The RING is faint by construction in
   this palette, in both themes, for every control it dresses. The NUMBER is
   what reads: --text-4 on --well measures 6.07:1 dark and 4.52:1 light. */
.stepper {
  --step-mark: 28px;   /* the logo mark's own box; a 500-weight --t-2 numeral centres in it */
  display: flex;
  align-items: flex-start;
}
.stepper > .step {
  flex: 1 1 0;   /* equal widths, or the connector cannot reach the next mark */
  min-width: 0;
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  padding-inline: var(--space-2);
  text-align: center;
}
.step-index {
  display: grid;
  place-items: center;
  width: var(--step-mark);
  height: var(--step-mark);
  flex-shrink: 0;
  border-radius: var(--r-pill);
  background: var(--well);
  --tint-ground: transparent;
  border: 1px solid var(--line);
  color: var(--text-4);   /* 6.07:1 dark, 4.52:1 light on --well */
  font: 500 var(--t-2)/1 var(--font-mono);
  font-variant-numeric: tabular-nums;
  transition:
    background var(--dur-tint) var(--ease),
    border-color var(--dur-tint) var(--ease),
    color var(--dur-tint) var(--ease);
}
.step-index svg { width: 14px; height: 14px; display: block; }
/* a running step drops the spinner into the mark; currentColor is the glyph
   colour already measured against whatever fill the state paints */
.step-index .spinner { border-color: currentColor; border-top-color: transparent; }

.step-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.step-label {
  font: 500 var(--t-2)/1.35 var(--font-sans);
  color: var(--text-3);   /* 6.35 / 5.49 dark, 5.27 / 5.92 light */
  overflow-wrap: anywhere;
}
.step-desc {
  font: 400 var(--t-1)/1.4 var(--font-sans);
  color: var(--text-4);   /* 5.47 / 4.73 dark, 4.94 / 5.55 light */
  overflow-wrap: anywhere;
}

/* the connector runs from this mark's edge to the next one's. Every step is
   the same width, so -50% lands on the next centre and the mark's radius plus
   the row gutter pulls it back to the next edge. */
.stepper > .step:not(:last-child)::after {
  content: '';
  position: absolute;
  top: calc(var(--step-mark) / 2 - 1px);
  left: calc(50% + var(--step-mark) / 2 + var(--space-2));
  right: calc(-50% + var(--step-mark) / 2 + var(--space-2));
  height: 2px;
  background: color-mix(in srgb, var(--text-4) 28%, var(--tint-ground));
}

.step.done .step-index {
  background: var(--accent);
  --tint-ground: transparent;
  border-color: var(--accent);   /* 7.49 / 6.48 dark, 4.09 / 4.60 light */
  color: var(--on-accent);       /* 8.22:1 dark, 4.60:1 light on the fill */
}
.step.done .step-label { color: var(--text-2); }   /* 8.43 / 7.30 dark, 7.44 / 8.36 light */
/* the run got this far. Written with the child combinator, because the
   connector's own rule carries `:not(:last-child)` and a bare `.step.done`
   is outranked by it: measured, the done connector painted the neutral. */
.stepper > .step.done::after { background: var(--accent); --tint-ground: transparent; }

.step[aria-current="step"] .step-index {
  background: var(--accent-quiet);
  border-color: var(--accent);
  color: var(--accent-text);   /* 6.67:1 on --bg, 5.80:1 on --surface-1, dark */
}
.step[aria-current="step"] .step-label { color: var(--text); }

.step.error .step-index {
  background: var(--danger);
  --tint-ground: transparent;
  border-color: var(--danger);
  color: var(--on-danger);   /* 6.58:1 dark, 6.01:1 light on the fill */
}
.step.error .step-label { color: var(--danger); }   /* 5.98 / 5.18 dark, 5.36 / 6.02 light */

/* ---- Stepper, vertical ----
   The same marks stacked, the connector turned on its side, the text beside
   the mark instead of under it. */
.stepper-vertical { flex-direction: column; align-items: stretch; }
.stepper-vertical > .step {
  flex: 0 0 auto;
  flex-direction: row;
  align-items: flex-start;
  gap: var(--space-3);
  padding-inline: 0;
  padding-bottom: var(--space-5);
  text-align: left;
}
.stepper-vertical > .step:last-child { padding-bottom: 0; }
.stepper-vertical > .step .step-text { padding-top: 3px; }   /* the label's cap height against the mark's centre */
/* --space-1 of clearance, not --space-2. Horizontally the connector crosses a
   whole column and 8px each way is air; vertically the space between two marks
   is whatever the row's own text leaves, 28.6px on a two-line step, and 8px
   each way took that down to a 12.6px stub. 4px leaves 20.6px of line. */
.stepper-vertical > .step:not(:last-child)::after {
  top: calc(var(--step-mark) + var(--space-1));
  bottom: var(--space-1);
  left: calc(var(--step-mark) / 2 - 1px);
  right: auto;
  width: 2px;
  height: auto;
}

/* ---- Stepper, compact ----
   Marks and connectors only, for a viewport that cannot hold five labels. The
   labels go sr-only rather than display:none, so the step names survive for
   assistive tech; pair it with a heading that names the current step, or a
   sighted user has nothing to read. */
.stepper-compact { --step-mark: 22px; }
.stepper-compact .step-index { font-size: var(--t-1); }
/* the compact stepper's label is visually hidden, not gone: it rides
   `tokens.css`'s `.sr-only` rule, which names this selector. */

/* ---- Meter ----
   A level against a capacity: storage used, seats taken, rate budget spent.

   The fill rides scaleX, not width, so nothing here is a layout change: the
   track clips at its own pill radius, which is why the fill needs no radius of
   its own and the scale cannot distort a cap. --meter-v is registered so an
   unset meter reads empty instead of full, and so the number carries a type.

   The track is --well, the token whose own comment names tracks. It reads 1.28:1
   on --surface-1 dark against --surface-2's 1.18, and a meter lives on a card.

   Native <meter> is rejected for the same reason native <select> was: the bar
   is UA chrome and ignores the theme. role="meter" on a div styles cleanly and
   says the same thing to assistive tech. */
@property --meter-v { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --meter-limit { syntax: "<number>"; inherits: true; initial-value: 0; }

.meter { display: flex; flex-direction: column; gap: var(--space-2); }
.meter-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
}
.meter-label {
  font: 500 var(--t-2)/1.3 var(--font-sans);
  color: var(--text-2);   /* 8.43 / 7.30 dark, 7.44 / 8.36 light */
  min-width: 0;
}
.meter-value {
  font: 500 var(--t-2)/1.3 var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--text);   /* 11.70 / 10.12 dark, 11.98 / 13.47 light */
  white-space: nowrap;
}
.meter-body { position: relative; }
/* the tick is 6px with 2px of air under it, so the reserved strip is --space-2 */
.meter-body:has(.meter-mark) { padding-top: var(--space-2); }
.meter-track {
  height: var(--space-2);
  background: var(--well);
  --tint-ground: transparent;
  border-radius: var(--r-pill);
  overflow: hidden;
}
.meter-fill {
  height: 100%;
  background: var(--accent);   /* 8.31:1 dark, 3.74:1 light on the track */
  --tint-ground: transparent;
  transform-origin: left center;
  transform: scaleX(var(--meter-v));
  transition: transform var(--dur-far) var(--ease), background var(--dur-tint) var(--ease);
}
/* The limit line sits ABOVE the track, never across it. Crossing it would put
   --text-3 on a --warning fill, which is 1.3:1 dark: no neutral in the ramp
   reads on both the track and every fill. On the surface it is 6.35 / 5.49
   dark and 5.27 / 5.92 light. It has no voice, so `.meter-hint` names the limit. */
.meter-mark {
  position: absolute;
  top: 0;
  left: calc(var(--meter-limit) * 100%);
  transform: translateX(-50%);
  width: 2px;
  height: 6px;
  border-radius: var(--r-pill);
  background: var(--text-3);
  --tint-ground: transparent;
}
.meter-hint {
  font: 400 var(--t-1)/1.4 var(--font-sans);
  color: var(--text-4);   /* 5.47 / 4.73 dark, 4.94 / 5.55 light */
}

.meter.warn .meter-fill { background: var(--warning); --tint-ground: transparent; }   /* 10.74:1 dark, 4.80:1 light on the track */
.meter.warn .meter-value { color: var(--warning); }       /* 9.68 / 8.37 dark, 5.25 / 5.91 light */
.meter.over .meter-fill { background: var(--danger); --tint-ground: transparent; }    /* 6.64:1 dark, 4.89:1 light on the track */
.meter.over .meter-value { color: var(--danger); }        /* 5.98 / 5.18 dark, 5.36 / 6.02 light */
/* no reading yet: the value says so and the empty track is the initial value */
.meter.unknown .meter-value { color: var(--text-4); }
/* one tone for both, the setting row's reading: stepping each line down by one
   lands them on colours live text already wears. 3.89:1 dark, 4.23 light. */
.meter[aria-disabled="true"] :is(.meter-label, .meter-value) { color: var(--text-disabled); }
/* The disabled fill drops to the quiet form of its state colour, the
   `.toggle:disabled.on` precedent: one step quieter against what it sits on.
   The fill's ground is the --well track, and the quiet TOKEN is sized for
   surfaces, not tracks: --accent-quiet composites to 1.36:1 dark and 1.16:1
   light over --well, far under the 3:1 graphics floor a fill is held to. The
   quiet form is therefore the state colour mixed INTO the track, at the
   quietest share whose painted composite clears the floor: accent 50% dark
   (3.03:1) and 83% light (3.01:1), warning 44% / 74% (3.09 / 3.04), danger
   58% / 66% (3.05 / 3.01). Every share is the measured floor: one point less
   drops under 3:1, and the two closest calls are light warning 73 at 2.9998
   and dark warning 43 at 2.9987. The light shares sit close to the full colour
   because the live accent itself clears the track at only 3.74:1 in Latte. */
.meter[aria-disabled="true"] .meter-fill {
  background: color-mix(in srgb, var(--accent) 50%, var(--well));
  --tint-ground: transparent;
}
.meter[aria-disabled="true"].warn .meter-fill {
  background: color-mix(in srgb, var(--warning) 44%, var(--well));
  --tint-ground: transparent;
}
.meter[aria-disabled="true"].over .meter-fill {
  background: color-mix(in srgb, var(--danger) 58%, var(--well));
  --tint-ground: transparent;
}
[data-theme="light"] .meter[aria-disabled="true"] .meter-fill {
  background: color-mix(in srgb, var(--accent) 83%, var(--well));
  --tint-ground: transparent;
}
[data-theme="light"] .meter[aria-disabled="true"].warn .meter-fill {
  background: color-mix(in srgb, var(--warning) 74%, var(--well));
  --tint-ground: transparent;
}
[data-theme="light"] .meter[aria-disabled="true"].over .meter-fill {
  background: color-mix(in srgb, var(--danger) 66%, var(--well));
  --tint-ground: transparent;
}

/* ---- Ring progress ----
   The meter's radial form, for a card that has height and no width. Same
   thresholds, same vocabulary, one number in the middle.

   pathLength="100" is what makes the dash independent of the radius: the
   browser rescales the path to 100 units, so the dasharray is read as percent
   and the same CSS works at every size. Without it the markup has to carry
   2*pi*r, and it goes stale the moment the radius changes.

   NAMED DEVIATION from transform-and-opacity: the sweep animates
   stroke-dasharray, because an arc's LENGTH is the value. A scale resizes the
   ring, a rotate spins it, and neither says 62 percent. */
@property --ring-v { syntax: "<number>"; inherits: true; initial-value: 0; }

.ring {
  /* geometry, not tokens: the inscribed square of a circle is 70.7 percent of
     its diameter, so 96 leaves 68px for a --t-6 value over a --t-1 label and
     64 leaves 45px, which holds the value alone */
  --ring-size: 96px;
  --ring-stroke: 8;   /* viewBox units: 7.68 css px at 96, 5.12 at 64 */
  display: inline-grid;
  place-items: center;
  width: var(--ring-size);
  height: var(--ring-size);
  flex-shrink: 0;
}
.ring > svg {
  grid-area: 1 / 1;
  width: 100%;
  height: 100%;
  display: block;
  transform: rotate(-90deg);   /* the sweep starts at twelve o'clock */
}
.ring circle {
  fill: none;
  stroke-width: var(--ring-stroke);
  /* The radius lives in the MARKUP, on one contract everywhere: viewBox 0 0 100 100,
     cx/cy 50, r 45. A stroke straddles its path, so the outer edge lands at
     45 + 8/2 = 49 inside a 50-unit half-box and nothing clips. The octagon that
     shipped was a second markup, viewBox 0 0 36 36 with r 15.9155: 15.9155 + 4
     reached 19.92 against an 18-unit half-box, so the box cut the circle flat on
     four sides.
     NEVER author `r` here. A CSS `r` is in USER UNITS, so one px value means two
     different rings across two viewBoxes, and the fix for the octagon was exactly
     that: `r: 13.5px` measured a 33.6px ring inside its 96px box on viewBox 100 and
     a 21.33px stroke on viewBox 36, against a .ring-center measuring 46.8 x 41.2
     (62.4 diagonal). On the contract the hole measures 78.7px at --ring-size 96 and
     52.5px at 64, so the label clears the arc at both. The markup is what holds it,
     since the CSS can no longer see it. */
}
.ring-track { stroke: var(--well); }
.ring-fill {
  stroke: var(--accent);   /* 8.31:1 dark, 3.74:1 light on the track */
  stroke-linecap: butt;    /* a round cap paints a dot at zero */
  /* the gap collapses AT full, never below it: sign(1 - v) is 1 under, 0 at 1
     and negative over, so a complete ring paints closed and no partial ring
     moves. A literal `v*100 100` at v = 1 draws a dash exactly as long as the
     path, and Chrome antialiases the joint where it meets itself into a dark
     notch at twelve o'clock (measured 2 device px at 4x); `100 0` closes it
     and a negative gap paints the whole stroke the same way. Every RESTING
     pixel under 100 percent is unchanged, since the gap is literally 100 there.
     A down-sweep from a full ring is the one thing that moves: the gap
     interpolates from a negative value and clamps at 0, so the ring reads
     closed until the dash reaches 84 rather than 100, about 130ms of the 260ms
     transition. Up-sweeps are identical to the old form, both crossing at 100.
     THE FIRST DECLARATION IS THE FALLBACK, not a leftover: an engine with no
     `sign()` drops the second line and keeps the old notch, where dropping the
     pair would paint every determinate ring closed at every value. */
  stroke-dasharray: calc(var(--ring-v) * 100) 100;
  stroke-dasharray: calc(var(--ring-v) * 100) calc(100 * sign(1 - var(--ring-v)));
  transition: stroke-dasharray var(--dur-far) var(--ease), stroke var(--dur-tint) var(--ease);
}
.ring-center {
  grid-area: 1 / 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  max-width: 70%;   /* the inscribed square again */
  text-align: center;
}
.ring-label {
  font: 500 var(--t-1)/1.2 var(--font-sans);
  color: var(--text-3);
}
.ring-sm { --ring-size: 64px; }
.ring-sm .ring-value { font-size: var(--t-4); }

.ring.warn .ring-fill { stroke: var(--warning); }
.ring.warn .ring-value { color: var(--warning); }
.ring.over .ring-fill { stroke: var(--danger); }
.ring.over .ring-value { color: var(--danger); }
.ring.unknown .ring-value { color: var(--text-4); }
/* indeterminate, her call 2026-09-05: the tail breathes. The head is pinned to
   the rim at the turn speed and never varies; behind it the tail swings as the
   arc opens and closes, so there is always a steady point to follow. The bound
   that picks the numbers: dasharray L 100 with dashoffset d draws the arc at
   [-d, -d+L], and under the spin's theta' (71.429 u/s at 1.4s) both ends stay
   forward iff the moving end's share of |L'| stays under theta'. An ease-in-out
   alternate breath peaks at 1.7241 * A / half-period, which at these numbers
   leaves 51.7 percent of the rim to spare. The pattern must not WRAP either:
   L 100 has period L+100, longer than the path, so the dash is laid once from
   -offset and whatever falls outside [0,100] paints nothing. Anchoring the head
   at the path's midpoint keeps every amplitude here inside the path. */
/* the trough of the reduced-motion pulse, per theme, set where the arc lands on
   the 3:1 graphics floor over --well: 0.50 measures 3.03:1 dark, 0.84 measures
   3.02:1 light. Light needs the higher trough because --accent on --well starts
   at 3.74:1 there against 8.31:1 dark, so it has 0.74 of headroom to spend. */
.ring { --ring-hold-dim: 0.50; }
[data-theme="light"] .ring { --ring-hold-dim: 0.84; }
.ring.loading .ring-fill {
  stroke-dasharray: 16 100;
  stroke-dashoffset: -34;
  transform-box: view-box;
  transform-origin: center;
  animation: spin 1.4s linear infinite,
             ring-tail-breathe 1.5s ease-in-out infinite alternate;
  transition: none;
}
/* Restated at the loading rule's own (0,3,0) and after it: a media query adds
   no specificity, so the shipped reduce blocks at (0,1,0) cannot reach this.
   The arc holds the arithmetic mid of its own breath, at its own anchor,
   pulsing to the floor rather than turning. */
@media (prefers-reduced-motion: reduce) {
  .ring.loading .ring-fill {
    animation: ring-hold 1.4s ease-in-out infinite;
    stroke-dasharray: 31 100; stroke-dashoffset: -19;
  }
}

/* ---- Avatar, completed ----
   The shipped `.avatar` is the initials disc. This adds the photo, the size
   ladder, the stacked group with its overflow count, the presence dot, and the
   unread mark an icon button needs.

   --dot-ring is the surface a mark is cut out of. It defaults to the page;
   set it on a card, a navbar or the avatar itself when the ground is not --bg. */
/* The photo covers the padding box and leaves the accent hairline as the ring,
   so the initials underneath are the broken-image fallback: give the img an
   empty alt and the disc keeps the name. */
.avatar > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--r-pill);
}
/* dark keeps --accent-text at 6.67 / 5.80; light takes the shared remix */

/* the ladder is the spacing scale: 24 / 32 / 40 / 48 */
.avatar-sm { --av-size: var(--space-6);  font-size: 10px; }
.avatar-lg { --av-size: var(--space-10); font-size: var(--t-3); }
.avatar-xl { --av-size: var(--space-12); font-size: var(--t-4); }

/* A stack, so the overlap IS the layout: the one place a negative margin
   between siblings is the design and not a gap in disguise. Display only.
   Making the members focusable puts overlapping tap targets on a phone; wrap
   the whole stack in one control instead. */
.avatar-group { display: flex; align-items: center; }
/* A QUARTER OF THE DISC, AT EVERY RUNG, RING INCLUDED. The fixed -8px this
   shipped as was a quarter of the 32px base and nothing else: it covered 41% of
   an .avatar-sm and 21% of an .avatar-xl, once the next disc's 2px ring is
   counted, so the small end lost most of its second initial while the large end
   lost nothing. The ring is the 2px below and does not scale, so it comes out of
   the margin rather than riding on top of it, which is what keeps the total bite
   proportional. Measured with the widest two-letter initials: the covered text
   goes from 7.4px at 24 / 1.1px at 48 to 3.4 / 3.1 across the whole ladder. */
.avatar-group > * + * { margin-left: calc(2px - var(--av-size, 32px) / 4); }
.avatar-group > .avatar { box-shadow: 0 0 0 2px var(--dot-ring, var(--bg)); }
.avatar-more {
  background: var(--surface-2);
  --tint-ground: transparent;
  border-color: var(--line);
  color: var(--text-3);   /* 4.65:1 dark, 4.96:1 light on --surface-2 */
  font-family: var(--font-mono);
}

/* A 30 percent dot flush to the corner rides the disc's 45-degree edge at every
   step of the ladder. Its box resolves against the padding box, so the centre
   lands at 0.85D - 0.7 against an edge at 0.8536D: a 0.79 to 0.87px inset from
   24 through 48, measured 33.3 against 34.14 on the 40px step. The mark reads
   attached to the disc rather than balanced on its rim. */
.avatar-presence {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 30%;
  height: 30%;
  border-radius: var(--r-pill);
  background: var(--text-4);   /* 5.47:1 dark, 4.94:1 light against a --bg ring */
  --tint-ground: transparent;
  box-shadow: 0 0 0 2px var(--dot-ring, var(--bg));
}
.avatar-presence.online  { background: var(--success); --tint-ground: transparent; }   /* 8.89 dark, 4.85 light */
.avatar-presence.away    { background: var(--warning); --tint-ground: transparent; }   /* 9.68 dark, 5.25 light */
.avatar-presence.busy    { background: var(--danger); --tint-ground: transparent; }    /* 5.98 dark, 5.36 light */
.avatar-presence.offline { background: var(--text-4); --tint-ground: transparent; }

/* The unread mark for an icon button, which the navbar wanted and had no way
   to say. It sits outside the button's box, so it can never land on the glyph,
   and the ring separates it from the bar behind. The count and the word
   "unread" belong in the button's own accessible name; a pseudo has no voice.
   Not combinable with `.btn-loading`: both own ::after. */
.btn-icon.unread { position: relative; }
.btn-icon.unread::after {
  content: '';
  position: absolute;
  top: -2px;
  right: -2px;
  width: 8px;
  height: 8px;
  border-radius: var(--r-pill);
  background: var(--accent);   /* 7.49:1 dark, 4.09:1 light against a --bg ring */
  --tint-ground: transparent;
  box-shadow: 0 0 0 2px var(--dot-ring, var(--bg));
}

/* ---- Banner ----
   One per page, at the top: a condition the whole page is under.

   The callout's material, because it is the same material: a 6 percent tint
   over surface-1 and the lit edge. What differs is the placement and the
   dismiss, not the volume. On the page column it is a card; outside `.page`
   it spans the viewport and `.banner-inner` holds its content to the column,
   the navbar's own construction. No --vp-w, no negative margins.

   Text on all four tints, worst pairs: title 8.86:1 dark and 12.23 light, body
   6.39 / 7.59, icon 4.74 / 4.24 against the 3:1 graphics floor, and the close
   at --text-3 4.81 / 5.38. --text-4 was the first choice and lands at 4.14 on
   the dark warning tint. */
.banner {
  flex-wrap: wrap;
  padding: var(--space-3) var(--space-4);
}
/* THE four tints, and they live here rather than in the callout block for one
   reason: `.banner`'s own untinted default above is (0,1,0) and so are these, so
   a tint declared earlier in the file would lose to it and every banner would
   paint plain surface-1. The validation summary is the danger one, and its tint
   resolves to #37313F dark, #F9F0F4 light. */
.callout-info,    .banner-info    { background: color-mix(in srgb, var(--info)    6%, var(--surface-1)); --tint-ground: transparent; }
.callout-success, .banner-success { background: color-mix(in srgb, var(--success) 6%, var(--surface-1)); --tint-ground: transparent; }
.callout-warning, .banner-warning { background: color-mix(in srgb, var(--warning) 6%, var(--surface-1)); --tint-ground: transparent; }
.callout-danger,  .banner-danger,
.form-summary                     { background: color-mix(in srgb, var(--danger)  6%, var(--surface-1)); --tint-ground: transparent; }

/* Only in the bleed form. The column itself is shared with .navbar-inner. */
.banner-inner { align-items: center; flex-wrap: wrap; }
.banner-bleed {
  border-radius: 0;
  border-bottom: 1px solid var(--line-soft);
  padding-inline: var(--space-8);   /* the page column's own gutter */
}

.banner-info    .banner-icon { color: var(--info); }
.banner-success .banner-icon { color: var(--success); }
.banner-warning .banner-icon { color: var(--warning); }
.banner-danger  .banner-icon { color: var(--danger); }

/* 200px is the basis that drops the ACTIONS to their own line at 390 while
   keeping a plain one-sentence banner on a single row: measured, 390 leaves
   256px beside the icon and the close, and a 260px basis wrapped the lone
   close button onto a second row for no reason */
.banner-body { flex: 1 1 200px; min-width: 0; }
.banner-text {
  font-size: var(--t-3);
  color: var(--text-2);
  overflow-wrap: anywhere;
}
.banner-title + .banner-text { margin-top: 2px; }
.banner-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-left: auto;
}
/* Both take the auto margin so a wrapped row still ends at the right edge, and
   the close gives its own up when it directly follows the actions, or the two
   would split the free space and land at opposite ends of that row. */
.banner .close { margin-left: auto; }
.banner .banner-actions + .close { margin-left: 0; }

/* ============================================================
   The one remove control
   ============================================================
   A bare square carrying the × glyph, and the only remove class in the system:
   the banner's and the toast's dismiss, the modal's close, the chip's × and a
   file row's remove all wear it. The chip's × is the same control held to the
   pill's scale by the chip-scoped rules in the filter section. */
/* THE bare icon button, four boxes and one costume. Only the size, the radius
   and the rest colour below tell the four apart; the hover is the system's
   neutral one, declared with the menu row. */
.close,
.copy-chip-btn,
.select-clear,
.row-toggle {
  display: grid;
  place-items: center;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
}
/* every one of them but the row toggle, which sits in a grid cell rather than a
   flex row and has nothing to shrink against */
.close,
.copy-chip-btn,
.select-clear { flex-shrink: 0; }
.close,
.copy-chip-btn,
.select-clear,
.row-toggle { transition: color var(--dur-tint) var(--ease), background var(--dur-tint) var(--ease); }
.close {
  width: var(--ctl-sm);
  height: var(--ctl-sm);
  border-radius: var(--r-1);
  color: var(--text-3);   /* 5.49:1 dark / 5.92:1 light on --surface-1, 7.05 / 4.82 on --well */
}
.close svg { width: 14px; height: 14px; display: block; }
/* THE remove hover, one glyph and one grey well wherever the control sits.
  --danger on the --well fill: 6.62:1 dark, 4.89:1 light. The well reads
  1.28:1 dark and 1.23:1 light on a card, 1.39-1.46 / 1.12-1.13 on the four
  banner tints, 1.28-1.51 on the chips. Where the ground is not a card the
  fill is named per ground below, because no single grey reads on every
  ground: --surface-2 measures 1.18:1 on a dark card and 1.08-1.10 on the
  light tints, and --well itself is 1.00 on the light well row.

  LIGHT HOLDS THREE GROUNDS NO GREY CLEARS 1.2 ON: the four banner tints
  (well 1.12-1.13; the nearest fill, --surface-3, reads 1.19-1.21 and drops
  the glyph to 4.60), the select chip lifted by an open panel (1.03; the
  nearest, --surface-1, reads 1.19), and the glass panel's worst plate (see
  the glass rule below). --well stays because it is the one fill that holds
  the glyph's 4.5 floor everywhere in light, and every one of these grounds
  already sat under an invisible well before this rule: the neutral hover's
  --surface-2 measured 1.08-1.10 on the tints and 1.03 on the lifted chip.
  A visible well on any of the three needs a token this ramp does not have,
  which is her call, not a fill to invent here. The hover itself lives down
  in the select-chip block, placed after the one rest rule that ties it. */

/* the exit is the chip's shape at the chip's speed; the page removes the node
   on animationend, and that removal is the one frame the page reflows */
.banner.closing { animation: banner-out var(--dur-out-near) var(--ease) both; }
@keyframes banner-out { to { opacity: 0; transform: translateY(-4px); } }

@media (max-width: 768px) {
  .banner-bleed { padding-inline: var(--space-4); }
}

/* ---- Coarse pointers ----
   The summary is the disclosure's clickable row and has never carried the
   floor: --space-2 around a --t-3 line box measures 32.8px, eleven short.
   Written at `.disclosure summary` rather than at the accordion, so the bare
   row shipped in v1 gets it too. */
@media (pointer: coarse) {
  .disclosure summary { min-height: var(--ctl-lg); }
  /* the square remove takes the floor itself; the chip's × stays 22px because
     its pill row already carries 44px. The floor for all three bare icon buttons
     is declared once, in the LAST coarse block: any earlier and `.copy-chip-btn`
     and `.row-toggle` would lose it to their own (0,1,0) box rules below. */
}

/* ---- Reduced motion ----
   Every restatement sits at its rule's own specificity or higher, and after it.
   The meter's scaleX and the ring's dasharray are the VALUE, not decoration, so
   they stay and only the travel to them goes. The accordion snaps, the way the
   rail collapse does: a size change has no fade to keep. */
@media (prefers-reduced-motion: reduce) {
  .accordion > .disclosure::details-content,
  .accordion > .disclosure[open]::details-content { transition: none; }
  /* the chevron still turns, it just arrives there */
  .disclosure summary svg { transition: none; }
  .meter-fill { transition: background var(--dur-tint) var(--ease); }
  .ring-fill { transition: stroke var(--dur-tint) var(--ease); }
  .banner.closing { animation-name: fade-out; }
}

/* ---- Market surfaces ----
   The seven sections a product page needs and a dashboard never does: hero,
   proof strip, feature grid, stat band, pricing table, cta, footer. Nothing
   here paints a second tone: `.band` is the tone device and every one of these
   sits on it or on the page.

   REGISTER. The demo strings are all lowercase, because these surfaces were
   built for a personal site. A sentence-case product page swaps the copy and
   keeps the classes; nothing in this block encodes a case.

   ONE GRID, SIX USERS. Every row here is N equal cells that collapse, and the
   collapse is keyed on the CELL's own minimum rather than on a viewport
   number: inside the 1136px page column a three-across cell is 362px and a
   four-across cell is 266, so no single breakpoint is right for both. The
   track floor is the larger of that minimum and the width one of N cells would
   have, so exactly N tracks stand until one of them would fall under the
   minimum, and auto-fill packs fewer from there. Three knobs per user:
   --cell-min, --cell-cols, --cell-gap. Each --cell-min below is a measured
   floor for its own cell, not a spacing step: no token names the width at
   which a tier or a mark stops reading, and the comment beside each one
   carries the viewport its count survives down to.

   auto-fill, not auto-fit. auto-fit collapses the empty tracks of a short last
   row, so five features in a three-across grid land as three cells then two
   half-page ones.

   min(--cell-min, 100%) is load-bearing. A cell minimum wider than the column
   (the hero's is 400px) sizes its track past the container and hangs a
   horizontal scrollbar off the whole document at 390px; the min() clamps the
   floor to the space that exists.

   Ratios in the comments were measured with tools/contrast.mjs against the
   declared tokens, on the three surfaces these sections sit on, --bg / --well
   / --surface-1.

   Nothing here animates but one colour transition, on the footer links, which
   is a paint change and survives reduced motion, so this block restates
   nothing in the reduced-motion section. */

.hero,
.proof,
.feature-grid,
.claim-row,
.pricing,
.footer {
  display: grid;
  column-gap: var(--cell-gap);
  grid-template-columns: repeat(auto-fill, minmax(
    max(min(var(--cell-min), 100%),
        (100% - (var(--cell-cols) - 1) * var(--cell-gap)) / var(--cell-cols)),
    1fr));
}

/* ---- Hero ----
   Eyebrow, headline, one sentence, an action pair, and the slot the product
   shot lands in. Two cells, and the pair stacks under 928px of viewport, which
   is where a 400px copy column stops holding the 528px measure comfortably.

   No padding of its own: the band or the page stack owns the section's
   vertical space, the same contract every other section in this block keeps.

   It holds at 3440px because it does not try to: the hero sits on `.page`,
   which caps at --page-w, so the widest screen gets a centred 1200px column
   and not a 3.4k headline. A build that wants more raises --page-w once.

   The headline is `.page-title` and the sentence is `.section-lede`, the two
   the system already ships: --t-9 falling to --t-8 under 768px, overflow-wrap
   on the title because a headline is content, and the measure cap on the lede.
   The hero's lede alone steps up to --t-5, so the one sentence under a 64px
   headline does not read as a caption. A headline twice as long wraps inside
   the cell and moves the media down; no rule here reads its length. */
.hero {
  --cell-min: 400px;
  --cell-cols: 2;
  --cell-gap: var(--space-16);
  row-gap: var(--space-12);
  align-items: center;
}
/* one column, copy centred on the measure, the shot full width under it */
.hero-stack { --cell-cols: 1; }
.hero-stack > .hero-copy {
  max-width: var(--page-narrow);
  margin-inline: auto;
  text-align: center;
}
.hero-stack > .hero-copy > .toolbar { justify-content: center; }

/* Flow, not flex: 16 under the eyebrow and under the headline, 24 over the
   actions, and one gap value for all three reads as a list of four things
   rather than as one block with an action under it. */
.hero-copy > :first-child { margin-block-start: 0; }
.hero-copy > .label { color: var(--text-4); }   /* 5.47:1 dark, 4.94:1 light on --bg */
.hero-copy > .page-title { margin-block-start: var(--space-4); }
.hero-copy > .section-lede {
  margin-block-start: var(--space-4);
  font-size: var(--t-5);
  line-height: 1.5;
}
.hero-copy > .toolbar { margin-block-start: var(--space-6); }

.hero-media { min-width: 0; }
.hero-media img { width: 100%; }

/* ---- Proof strip ----
   The row of marks under a hero. Cells are `.img-placeholder` at 5/2, set on
   the strip so one declaration re-ratios the whole wall, which is the knob the
   placeholder documents. Never a hand-drawn logo: a fake mark reads as a real
   claim about a customer.

   116px rather than a rounder floor: at that minimum five marks hold their row
   down to 708px of viewport (measured by bisection), so the strip keeps its
   count through the page column's own 768px step and wraps below it. A wall of
   small repeated marks may wrap; a row of cards may not, which is why the
   pricing row is stated and this one is not. */
.proof {
  --cell-min: 116px;
  --cell-cols: 5;
  --cell-gap: var(--space-6);
  --img-ratio: 5 / 2;
  row-gap: var(--space-6);
  align-items: center;
}
/* the strip's own lead-in, one line. --text-4 is the smallest step that still
   clears the floor on every surface: 5.47 / 6.07 / 4.73 dark, 4.94 / 4.52 /
   5.55 light. */
.proof-lede {
  margin-block-end: var(--space-6);
  font-size: var(--t-2);
  color: var(--text-4);
}

/* Quotes instead of marks: fewer, wider cells, and the ratio knob goes quiet.
   216px keeps the three across down to 728px, past the page column's own step;
   below that they wrap two and one, which a row of text blocks can carry and a
   row of cards cannot. */
.proof-quotes {
  --cell-min: 216px;
  --cell-cols: 3;
  align-items: start;
}
/* A pull quote. Not `.prose blockquote`: that one is a rule in the margin of a
   document, this is a cell that stands alone. --text-2 measures 8.43 / 9.36 /
   7.30 dark and 7.44 / 6.79 / 8.36 light on --bg / --well / --surface-1. */
.quote {
  font-size: var(--t-3);
  color: var(--text-2);
}
/* The opening mark sits above the line in a 22px slot, and it is a glyph, so it
   takes the accent's text token: 9.15 / 10.15 / 7.91 dark, 5.06 / 4.62 / 5.69
   light on the same three grounds. */
.quote::before {
  content: "\201C";
  display: block;
  height: 22px;
  font: 600 var(--t-8)/1 var(--font-sans);
  color: var(--accent-text);
}
.quote footer {
  margin-block-start: var(--space-3);
  font-size: var(--t-2);
  color: var(--text-4);
}

/* ---- Feature grid ----
   Three across is the case the numbers are tuned for; two and four fall out of
   the same formula with one knob changed. The cell is a mark, a title and a
   line, in flow, so a cell with no mark still starts at the top of its row.

   224px is the cell minimum. THE THRESHOLDS ARE GRID WIDTH, NOT VIEWPORT: the
   formula reads 100% of the grid's own box and knows nothing about the viewport,
   so the same grid drops a track earlier inside a sidebar shell or a narrow
   column than it does on a full page column. Measured by bisecting a cloned
   grid's container and counting rendered tracks: three across holds at 720px of
   grid width and falls to two at 719, two holds at 472 and falls to one at 471,
   and the four-across variant holds at 968 and falls to three at 967 rather than
   crushing four cells to 200px. The viewport numbers this carried before were
   those widths plus the page column's inline padding, added inconsistently: one
   --space-8 for the first and two for the second. */
.feature-grid {
  --cell-min: 224px;
  --cell-cols: 3;
  --cell-gap: var(--space-6);
  row-gap: var(--space-8);
}
.feature-grid-2 { --cell-cols: 2; }
.feature-grid-4 { --cell-cols: 4; }

.feature > :first-child { margin-block-start: 0; }
/* A row that MIXES marked and unmarked cells has to agree where the title line
   is, and in flow it cannot: the mark's 32px plus its 16px gap pushed a marked
   cell's title 48px below its unmarked neighbour's (measured on the market
   page's own mixed row). The unmarked cell reserves the mark's box instead, and
   only where the grid actually holds a mark: an all-unmarked grid fails the
   outer :has() and an all-marked one fails the inner :not(), so neither reserves
   anything. Without :has() this degrades to the flow behaviour above. */
.feature-grid:has(.icon-tile) .feature:not(:has(> .icon-tile)) > .feature-title {
  margin-block-start: calc(32px + var(--space-4));
}
/* --t-4, not the h3 the scale gives a section: a feature title is a label on a
   cell, and at --t-5 a four-across cell wraps every second title. */
.feature-title {
  margin-block-start: var(--space-4);
  font: 500 var(--t-4)/1.3 var(--font-sans);
  color: var(--text);
}
.feature-copy {
  margin-block-start: var(--space-2);
  font-size: var(--t-3);
  color: var(--text-3);   /* 6.35 / 7.05 / 5.49 dark, 5.27 / 4.82 / 5.92 light */
}

/* ---- Stat band ----
   Three or four numbers that are the page's claim, not its telemetry.
   `.stat-card` is the dashboard tile: a labelled surface with a delta and a
   sparkline, eight to a screen. This is type on a band, and there is one of it.

   --t-9 in mono, tabular, stepping to --t-8 under 768px exactly as
   `.page-title` does, so the biggest number on the page and the biggest word
   on it never disagree about how big that is.

   The under-768 column count is stated rather than left to the formula. Left
   to it, four cells pack into three tracks from 708px of viewport down to 536,
   measured with the media query overridden away: a 172px band with one number
   alone on a second row, reading as a number that did not fit. */
.claim-row {
  --cell-min: 152px;
  --cell-cols: 4;
  --cell-gap: var(--space-6);
  row-gap: var(--space-8);
}
.claim-row-3 { --cell-cols: 3; }
/* the two counts split below 768: four go two by two, three go down a column,
   because three numbers in two tracks leave one of them alone on a second row */
@media (max-width: 768px) {
  .claim-row { --cell-cols: 2; }
  .claim-row-3 { --cell-cols: 1; }
}

.claim {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  container-type: inline-size;
}
/* the claim is the page's number, so it takes the accent's text token:
   9.15 / 10.15 / 7.91 dark, 5.06 / 4.62 / 5.69 light on --bg / --well /
   --surface-1 */
.claim-value { font-size: var(--t-9); letter-spacing: -0.04em; color: var(--accent-text); }
@media (max-width: 768px) { .claim-value { font-size: var(--t-8); } }
/* The step down is a CELL width, not only a viewport one. A mono character at
   --t-9 advances 0.56em after the tracking, so a five-character claim ("4.5:1")
   is 179.2px, measured, and four cells in a tablet column run 158.25 to 179px
   from 769 to 852px of viewport, where the claim broke after "4.5:". A sidebar
   shell narrows the same cells at any viewport. */
@container (width < 180px) { .claim-value { font-size: var(--t-8); } }
.claim-label {
  font-size: var(--t-3);
  color: var(--text-3);
}

/* ---- Pricing ----
   Three tiers on cards, the marked one carrying the screen's single filled
   action. Three across or one, never two: a card left alone on a second row
   reads as a tier that did not fit. Both halves of that are load-bearing. The
   stack is the media query below, and the 216px minimum is what keeps three
   tracks alive until it fires: at 264px the three held only to 896px of
   viewport (measured), which left a 128px band of exactly the shape the
   media query exists to prevent.

   THE MARK IS NOT ELEVATION. --contact-2 buys a wider spread than --contact-1,
   not a deeper one: tokens.css carries both depths and is the one place they are
   written. Swapping them marks nothing the eye can read, so the mark is three
   things that do: the badge, the filled button, and a 2px accent edge inset
   along the top, which is the same device `.row-selected` uses for the row the
   system is pointing at. --accent on --surface-1 measures 6.48:1 dark and 4.60:1 light,
   over the 3:1 graphics floor. The card itself is never tinted: a coloured
   tier and a filled button in the same cell are two marks competing. */
.pricing {
  --cell-min: 216px;
  --cell-cols: 3;
  --cell-gap: var(--space-5);
  row-gap: var(--space-5);
}
@media (max-width: 768px) { .pricing { --cell-cols: 1; } }

/* stretch is what puts the price rows and the buttons at one height across
   three tiers whose lists are different lengths */
.tier {
  display: flex;
  flex-direction: column;
}
/* both classes on purpose: written one class longer than `.card`, so the pair
   cannot be decided by wherever this block lands in the file */
.tier.tier-marked { box-shadow: var(--contact-1), inset 0 2px 0 0 var(--accent); }

.tier-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
}
/* mono and tabular, like every other number the system prints. The period sits
   on the amount's baseline; --text-3 measures 5.49:1 on --surface-1 dark and
   5.92:1 light. */
.tier-price {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  margin-block-start: var(--space-4);
  font-size: var(--t-7);
}
.tier-period {
  font: 400 var(--t-3)/1 var(--font-sans);
  color: var(--text-3);
}
/* the column stretches it, so the action is the width of the tier it buys */
.tier > .btn { margin-block-start: var(--space-5); }

.tier-list {
  margin-block-start: var(--space-5);
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  font-size: var(--t-3);
  color: var(--text-2);   /* 7.30:1 dark, 8.36:1 light on --surface-1 */
}
.tier-list li {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
}
/* the mark sizes itself here for the same reason every other icon holder in
   the system does: a <use> that carries no class lays out at 300x150. 4px is
   the optical centring of a 14px glyph on a 21.7px line, and --space-1 is that
   number. --accent as a graphic: 6.48:1 dark, 4.60:1 light on --surface-1. */
.tier-list li > svg {
  width: 14px;
  height: 14px;
  flex-shrink: 0;
  margin-block-start: var(--space-1);
  color: var(--accent);
}

/* the terms line under the table, noun fragments comma-joined */
.pricing-note {
  margin-block-start: var(--space-6);
  font-size: var(--t-2);
  color: var(--text-4);   /* 5.47:1 dark, 4.94:1 light on --bg */
}

/* ---- CTA ----
   The closing ask: one line and one action, on a band. The line is
   `.section-title`, so the closing ask and a section heading are the same size
   and the page ends where it has been reading all along.

   The wrap threshold is the prose measure, the same number `.page-header`
   wraps its toolbar on: the line holds one measure or the action takes its own
   row. */
.cta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-5) var(--space-8);
}
.cta > .section-title {
  flex: 1 1 var(--page-narrow);
  min-width: 0;
  margin-top: 0;   /* the eyebrow it usually sits under is not here */
}
/* The action row is left at its default `flex: 0 1 auto` on purpose. Pinned at
   `0 0 auto`, the way `.page-header` pins its own toolbar, it cannot shrink
   below the max-content width of its buttons, so a pair of `.btn-lg` on the
   wrapped line runs 331px wide inside a 288px column and takes the document
   with it. Measured at 320px, before the fix. */

/* ---- Footer ----
   A brand block, columns of links, and the legal row under a hairline. The
   brand spans two cells, so the columns beside it stay the width they are
   everywhere else on the page.

   It collapses to two columns of links, never to one. One column turns four
   groups of five links into a twenty-item wall, which is the failure this
   layout is judged on. Under 768px the brand takes its own full row instead of
   spanning: a two-cell span in a one-track grid creates an implicit second
   column and overflows the page, which is what happens at 320px.

   The footer paints no tone of its own. Wrap it in `.band .band-well` for the
   tone change most footers want. */
.footer {
  --cell-min: 148px;
  --cell-cols: 5;
  --cell-gap: var(--space-8);
  row-gap: var(--space-10);
}
.footer-brand {
  grid-column: span 2;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-4);
}
@media (max-width: 768px) { .footer-brand { grid-column: 1 / -1; } }
/* the sidebar's own brand pair, on a link: `.sona` and `.logo-name` are
   already the system's mark and wordmark */
.footer-logo {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  text-decoration: none;
}
.footer-logo:hover { text-decoration: none; }
.footer-brand p {
  font-size: var(--t-3);
  color: var(--text-3);   /* 7.05:1 dark, 4.82:1 light on --well */
}

.footer-col {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.footer-links {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.footer-links a {
  font-size: var(--t-3);
  color: var(--text-3);   /* 7.05:1 dark, 4.82:1 light on --well */
  text-decoration: none;
  transition: color var(--dur-tint) var(--ease);
}
.footer-links a:hover {
  color: var(--text);   /* 12.98:1 dark, 10.95:1 light on --well */
  text-decoration: none;
}

.footer-legal {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3) var(--space-5);
  margin-block-start: var(--space-10);
  padding-block-start: var(--space-5);
  /* --line, not --line-soft: this row's most likely ground is a well band, and
     there --line-soft measures 1.02:1 in Latte, which is no line at all.
     --line measures 1.52:1 dark and 1.11:1 light on --well, the same step
     --line-soft holds on --bg (1.20 and 1.11), which is the hairline the
     system already ships. */
  border-top: 1px solid var(--line);
  font-size: var(--t-2);
  color: var(--text-4);   /* 6.07:1 dark, 4.52:1 light on --well */
}

/* ---- Coarse pointers: the row carries the tap floor ----
   A footer link list is a stack of targets, not a link inside a sentence, so
   WCAG 2.5.8's inline exemption does not cover it. The buttons in every one of
   these sections come from the ladder and take their floor with them. */
@media (pointer: coarse) {
  .footer-links a,
  .footer-logo {
    display: flex;
    align-items: center;
    min-height: var(--ctl-lg);
  }
}

/* ---- Dash: the nine a metrics-and-management build still had to invent ----
   A date range picker, a tree, a status key, the three full-page states, a
   context menu, the route bar, a copy chip, the shortcut sheet, and the cue that
   says a scrolling box holds more.

   Nothing here introduces a surface, a text level, a shadow or a hue. Panels are
   `.popover`, so the glass block at the end of the file reaches them without a
   line here; rows are `.menu-item`; the selected row is the system's own inset
   accent edge; keycaps are `kbd`; the group label is `.label`.

   Ratios in the comments below were measured with tools/contrast.mjs against
   PAINTED PIXELS, not declarations: every translucent fill was sampled off a
   real rendered element, and the glass ratios are the worst case tokens.css
   measures its own ramp against, a white plate behind the panel in Mocha and a
   black one in Latte. Text is opaque, so a background pixel settles a pair.

   Two rules this block was written under, both of them mistakes already paid for
   elsewhere in the file. A component paints with NO JS: every hiding state sits
   behind a class a controller adds, so a page without the controller shows the
   finished thing rather than nothing. And a slot that holds an icon states the
   icon's size, or a `<use>` dropped into it lays out at zero. */

/* ---- Date range picker ----
   A `.select-trigger` over a `.popover`: a column of presets, a month grid, and
   the two ends of the range in words. The trigger, the panel, the preset rows
   and the caret all ship already, so the only new geometry is the grid itself.

   THE BAND IS THE CELLS. They butt (the table collapses its borders and no cell
   carries a gap), each paints its own full box, and only the ends carry a
   radius, so a run is continuous by construction. Drawn instead as a bar behind
   rounded cells it breaks at every seam, which is the shape a hand-built picker
   ships and then patches with negative margins.

   One tint, one fill, and the difference between a committed range and one being
   dragged out is the END, not the band: a committed end is filled, a tentative
   end is outlined. A second, weaker tint would have to be translucent, and a
   translucent tint over the glass panel composites twice - the trap
   the glass block's opaque quiet mix exists to answer.

   The day number is mono: it is a numeric readout, and a proportional face puts
   the 1s and the 8s on different centres down a column. */

.daterange {
  position: relative;
  /* the grid's own module. Every cell is square, so this is the row height too,
     and the coarse floor moves it rather than adding a second rule per part. */
  --dr-cell: var(--ctl);
}
/* the trigger is `.input.select-trigger`, so the height ladder, the focus ring,
   the invalid border and the coarse bump all arrive from where they live. Open
   IS a focus state - the trigger holds focus for the panel's whole life - so the
   accent border and `.input`'s own ring say it, and no second device is added. */
.daterange.open > .select-trigger { border-color: var(--accent); --ring: 3px; }
/* nothing picked yet: the trigger reads its placeholder at the hint level, the
   same step `.input::placeholder` takes */
.daterange .select-value.is-empty { color: var(--text-placeholder); }

.daterange-panel {
  position: absolute;
  inset-block-start: calc(100% + var(--space-1));
  inset-inline-start: 0;
  /* the select menu's own z-index: both are a panel hanging off one control */
  z-index: 60;
  display: flex;
  flex-direction: column;
  /* The width clamp, for the coarse overhang bands: 44px cells put 334px of
     panel against a 320px viewport, and the document grows a scrollbar for a
     popover. The bound keeps a 40px gutter beside the trigger's own offset and
     never bites a fine pointer: the fine panel's narrowest is 276 at a 320
     viewport against a 280 bound, and the 560 cap keeps the clamp off the
     beside-form (494 at its widest) at every width that can hold it. */
  max-width: min(560px, calc(100vw - 2 * var(--space-5)));
  /* no background and no border here: `.popover` carries both, and the glass
     block restates them at (0,1,0) after this one */
}
/* no room below, or no room to the end: the panel flips rather than clamps. A
   clamped panel covers the field that opened it. */
.daterange-up > .daterange-panel {
  inset-block-start: auto;
  inset-block-end: calc(100% + var(--space-1));
}
.daterange-end > .daterange-panel { inset-inline-start: auto; inset-inline-end: 0; }
/* Open folds the seam flat so the two read as one body. The trigger's own seam
   corner is the `.select` fold's job (--seal); the panel's is here, because a
   `.popover` keeps its own radius where `.select-menu` ships a flat top. Up,
   the seam is the trigger's top and the panel's bottom, so those flatten. */
.daterange.open > .daterange-panel { border-start-start-radius: 0; border-start-end-radius: 0; }
.daterange.daterange-up.open > .daterange-panel {
  border-start-start-radius: var(--r-2);
  border-start-end-radius: var(--r-2);
  border-end-start-radius: 0;
  border-end-end-radius: 0;
}
.daterange.daterange-up.open > .select-trigger {
  border-start-start-radius: 0;
  border-start-end-radius: 0;
  border-end-start-radius: var(--r-1);
  border-end-end-radius: var(--r-1);
}

.daterange-body { display: flex; }

.daterange-presets {
  display: flex;
  flex-direction: column;
  gap: 2px;                  /* the list panel's lane */
  padding: var(--space-1);   /* and its gutter */
  border-inline-end: 1px solid var(--line-soft);
  /* "last 30 days" at --t-3 measures 132px from column edge to column edge,
     gutters included; 160 leaves the longest shipped preset a whole line and no
     more. It stays out of the shared lane rule: this column turns into a
     wrapping row under 560px and would take that rule's column back.

     A WIDTH THAT MAY SHRINK, not a floor that may not. The panel is clamped,
     and `.popover` clips, so whatever the clamp takes has to come out of this
     column or out of the content: at `min-width: 160px; flex-shrink: 0` it came
     out of the content, and a preset label past the ceiling was cut inside the
     popover with no scroll and no ellipsis - measured 48px lost at a 35
     character label, identically at coarse 830, coarse 900 and fine 1440, which
     is what says it is the clamp and not any one viewport. Resting width is
     unchanged at 160. */
  width: 160px;
  min-width: 0;
  flex-shrink: 1;
}
/* the rows are `.menu-item` on `role="option"`, the shape `.select-menu` already
   uses, so the hover, the active fill and the 44px coarse floor all arrive with
   the class. Roving tabindex moves real focus, so the row needs no second
   keyboard marker. --text-2 on the panel 4.62:1 dark and 6.67:1 light; active,
   --accent-text on the panel's opaque quiet mix, 5.95:1 and 4.89:1.

   The one thing it does NOT take from `.menu-item` is nowrap: this row is in a
   column that shrinks, so it takes two lines rather than losing its end. The
   two rules tie at (0,1,0) and this one is later, which `props white-space`
   shows and `ties` cannot, since the two are different subjects. Without it a
   label past the shrunken column runs under the panel's clip and is cut with no
   ellipsis to say so - measured, "quarter to date" wanted 110px of a 97px
   button and lost 13 silently. */
.daterange-preset { padding-inline: 10px; white-space: normal; }   /* plus the column's own 4px */

.daterange-cal { padding: var(--space-3); }
.daterange-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  margin-bottom: var(--space-2);
}
.daterange-month {
  font: 500 var(--t-3)/1.2 var(--font-sans);
  color: var(--text);   /* 6.40:1 dark, 10.74:1 light on the panel worst case */
  /* the month name changes length; a fixed centre stops the two arrows walking */
  flex: 1 1 auto;
  text-align: center;
}

.daterange-grid { border-collapse: collapse; }
/* a weekday name is a column header, which is one of the three places the
   tracked caps are sanctioned, and `.table-wrap thead th` is the same treatment.
   --text-4 resolves to --glass-dim inside the panel: 4.62:1 dark, 4.73:1 light. */
.daterange-dow {
  width: var(--dr-cell);
  height: var(--space-6);
  text-align: center;
  color: var(--text-4);
}
.daterange-grid td { padding: 0; }

.daterange-day {
  display: block;
  width: var(--dr-cell);
  height: var(--dr-cell);
  padding: 0;
  border: none;
  border-radius: 0;
  background: none;
  /* the UA centres a button's text and `tokens.css` gives that back to the page,
     so a fixed-width cell has to restate it: the numbers sat 10.5px left of the
     fills that mark them, and today's dot, which centres on the cell, agreed
     with the fill rather than with the number */
  text-align: center;
  font: 400 var(--t-2)/1 var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
  /* --text, not --text-2. INSIDE THE GLASS PANEL --text-2 AND --glass-dim ARE
     THE SAME VALUE in Mocha, so an outside day at --text-4 had no step off a
     normal one at all: both measured 4.62:1 on the same ground. The number is
     the panel's content, so it takes the top of the ramp and the dimmed states
     have somewhere to go. 6.40:1 dark, 10.74:1 light. */
  color: var(--text);
  cursor: pointer;
  position: relative;
  transition: background var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease);
}
/* the band runs to the row's own ends, so a range that wraps a week is not cut
   square against the panel's padding */
.daterange-grid td:first-child > .daterange-day {
  border-start-start-radius: var(--r-1);
  border-end-start-radius: var(--r-1);
}
.daterange-grid td:last-child > .daterange-day {
  border-start-end-radius: var(--r-1);
  border-end-end-radius: var(--r-1);
}

/* a day from the neighbouring month. It stays in the grid, because a week with
   three cells in it is not a week. --glass-dim on the panel, 4.62 / 4.73:1. */
.daterange-day.is-outside { color: var(--text-4); }
/* the system's own disabled costume, the one `.btn` and `.input` carry. The old
   alpha read as a step on every ground and cleared a floor on none: on this panel
   it measured 2.17:1 dark and 1.83 light. --text-disabled resolves to
   --glass-disabled here, which is the lowest value still over 3:1 on the panel's
   worst case, 3.27 dark and 3.37 light. Dark steps a full 0.100 of lightness under
   the --glass-dim an outside day takes. `pointer-events` stays on, so the cell
   keeps its place in the grid's roving focus. */
.daterange-day:disabled { cursor: not-allowed; }

/* today is a dot under the number, never a fill: a fill has to survive every
   range state painted over it, and currentColor carries the dot onto the accent
   end without a second rule */
.daterange-day[aria-current="date"] { font-weight: 500; }
.daterange-day[aria-current="date"]::after {
  content: '';
  position: absolute;
  inset-inline-start: 50%;
  inset-block-end: 15%;   /* a share of the cell, so the 44px coarse cell keeps it off the floor */
  translate: -50% 0;
  width: 3px;
  height: 3px;
  border-radius: var(--r-pill);
  background: currentColor;
}

/* --accent-text on the panel's opaque quiet mix: 5.95:1 dark, 4.89:1 light */
.daterange-day.is-in-range { background: var(--accent-quiet); color: var(--accent-text); }
/* --on-accent on the fill: 8.22:1 dark, 4.62:1 light */
.daterange-day:is(.is-start, .is-end) {
  background: var(--accent);
  --tint-ground: transparent;
  color: var(--on-accent);
  font-weight: 500;
}
.daterange-day.is-start { border-start-start-radius: var(--r-1); border-end-start-radius: var(--r-1); }
.daterange-day.is-end { border-start-end-radius: var(--r-1); border-end-end-radius: var(--r-1); }
/* THE inset focus ring, and every row in the system that needs one takes it from
   here. Each of these clips at its own edge, so the page's 3px outward ring
   is cut on a start or end edge, lands on a neighbouring cell, or rules across
   the body under an open summary. The base ring pairs its outline with a 1px
   --bg separator shadow, which at a negative offset is a stray keyline drawn
   around the box, so it goes with it. Her call 2026-09-03: all match.
   Nothing here restates the selected row's edge: a selected tree row's band is
   the whole active mark, and an inset edge would trace its radius as a crescent.

   It lives HERE, in the middle of the calendar, for one reason: the rule right
   below ties it at (0,2,0) and has to win, so a focused preview end keeps its
   accent ring. Every other member is unopposed from this position. */
.disclosure summary:focus-visible,
.sidebar-collapse:focus-visible,
.daterange-day:focus-visible,
.tree-row:focus-visible,
.ctxmenu > .menu-item:focus-visible,
.popover-menu .menu-item:focus-visible,
.daterange-preset:focus-visible { outline-offset: -2px; box-shadow: none; }
/* the end being dragged out is outlined, not filled. The band already shows the
   range; a second fill would say this end is committed when it is not. The ring
   is a graphic on the panel: 4.10:1 dark and 3.67:1 light, over the 3:1 floor. */
.daterange-day.is-preview-end {
  box-shadow: inset 0 0 0 1.5px var(--accent);
  border-start-end-radius: var(--r-1);
  border-end-end-radius: var(--r-1);
}
/* and it rounds that end only where the band STOPS. Rounded against a cell that
   carries the band, the ring reads as a second object cut into the run: the
   corner has to answer the neighbour, which is why the test is on the cell and
   not on the day. Her call 2026-09-04. */
.daterange-grid td:has(+ td > .daterange-day:is(.is-in-range, .is-start, .is-end)) > .daterange-day.is-preview-end {
  border-start-end-radius: 0;
  border-end-end-radius: 0;
}

/* hover paints the neutral cell and leaves every range state alone: the state is
   already the feedback, and a hover fill over the band breaks the run.
   ORDER IS THE ONLY THING DECIDING THESE THREE. `:not()` carries its argument's
   specificity, so the base measures (0,3,0) and both restatements tie it there
   rather than leading it by a class: each has to stay BELOW the base, and a
   resting screenshot cannot see them swap. The state-merge check drives
   the hover and reads the paint, which is what holds it.
   --text on --surface-2 measures 8.56:1 dark and 11.28:1 light; --on-accent on
   --accent-hover 10.03:1 and 5.90:1. */
.daterange-day:hover:not(:disabled) { background: var(--surface-2); --tint-ground: transparent; }
.daterange-day.is-in-range:hover { background: var(--accent-quiet); color: var(--accent-text); }
.daterange-day:is(.is-start, .is-end):hover { background: var(--accent-hover); --tint-ground: transparent; color: var(--on-accent); }

/* on the two filled ends the inset ring takes the fill's own on-colour, which an
   accent ring would vanish into */
.daterange-day:is(.is-start, .is-end):focus-visible { outline-color: var(--on-accent); }

.daterange-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-top: 1px solid var(--line-soft);
}
/* the two ends in words, on aria-live, so an arrow-key move announces the same
   string the eye reads off the band. The grid alone states a shape. */
.daterange-range {
  font: 400 var(--t-2)/1.4 var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--text);   /* the same reason the day cell takes it */
}
.daterange-sep { color: var(--text-4); padding-inline: var(--space-1); }

/* The panel is 460px on one row, measured, so it stacks under 560 rather than at
   the file's own 768: at 768 it still fits and stacking it there wastes a whole
   viewport of width. */
@media (max-width: 560px) {
  .daterange-body { flex-direction: column; }
  .daterange-presets {
    flex-direction: row;
    flex-wrap: wrap;
    /* the side column's own 160px is a COLUMN width; stacked, the row is as
       wide as the panel */
    width: auto;
    min-width: 0;
    border-inline-end: none;
    border-block-end: 1px solid var(--line-soft);
  }
}
/* The preset column gives up its whole-line width before the panel gives up
   its shape. A 44px grid makes the beside-form 494 wide, and a shell whose
   content column starts 308 in leaves less than that, so the panel overhung
   the document from 769 to 801 (measured 33 and 2).

   THE CLAMP IS A LENGTH, NOT A CONTENT WIDTH, and that is the whole point:
   letting the column shrink without capping the panel leaves the panel exactly
   as wide as its longest preset string, so the overhang came back at a label
   two characters longer than the sheet's - measured at coarse 769, uniform
   labels, 12 characters clears and 14 overhangs by 3, 17 by 26, 20 by 46. 440
   is the bound the shell case allows (461 of column at 769, less the page's own
   gutters), so the panel is 440 whatever it holds, and the preset column's own
   `flex-shrink` and wrap pay for it.

   TRADING WIDTH FOR HEIGHT IS WHAT THIS AVOIDS: stacking the column takes the
   panel from 383 tall to 528, and a popover that deep hanging off a control is
   the case `.daterange-up` exists to flip, in a band where the viewport is wide
   enough that nothing needed to move. Measured in a 431px frame, the stacked
   form stood 145px past it. Under 560 the panel stacks anyway and that block's
   own `min-width: 0` owns it. */
@media (pointer: coarse) and (min-width: 561px) and (max-width: 820px) {
  .daterange-panel { max-width: min(440px, calc(100vw - 2 * var(--space-5))); }
}

/* ---- Tree navigation ----
   Nested rows that expand: a file tree, a nested resource list, a scope picker.
   `.sidebar`'s rows are flat under group labels; this is the recursive case.

   DEPTH IS THE NESTING, never a counter. Each level's guide line is a `::before`
   on the GROUP, so it spans exactly that group's own children and a tree ten
   deep needs no per-level class, no --depth custom property and no rule that
   knows how far down it is. The indent is the group's padding, which is what
   lets the line sit on the parent twist's centre at every level with one value.

   The selected row's band is the shared active-row band, and the whole mark:
   the quiet fill on the --r-1 corners every row already carries, starting at
   the row's own edge. The 2px accent capsule that used to sit beside it went
   with `.nav-indicator`, her call 2026-09-02.

   The band starts at the row's own edge rather than the tree's, which is what
   nesting the padding buys. A band that spans the full width at every depth
   needs the indent moved inside the row, and that needs the depth counter this
   construction exists to avoid. */

.tree {
  --tree-indent: var(--space-5);   /* one level */
  --tree-twist: 16px;              /* the affordance's box; the glyph in it is 14 */
}
.tree-group {
  position: relative;
  list-style: none;
  margin: 0;
  padding-inline-start: var(--tree-indent);
}
/* the line runs down the parent twist's own centre. Same translucent neutral the
   timeline rail carries, and for the same measured reason: no opaque token in
   either ramp reads on --bg and --surface-1 both. It is texture, not a graphic:
   the indent and the twist say the depth, and the line only traces it. */
.tree-group::before {
  content: '';
  position: absolute;
  inset-block: 0;
  inset-inline-start: calc(var(--space-2) + var(--tree-twist) / 2 - 1px);
  width: 2px;
  background: color-mix(in srgb, var(--text-4) 28%, var(--tint-ground));
}

.tree-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  min-height: var(--ctl-sm);
  padding: 0 var(--space-2);
  border: none;
  border-radius: var(--r-1);   /* the list panel's row radius, in every state */
  background: none;
  font: 400 var(--t-3)/1.3 var(--font-sans);
  color: var(--text-3);   /* 5.48:1 on --surface-1 and 6.34:1 on --bg dark, 5.92 / 5.28 light */
  text-align: start;
  text-decoration: none;
  cursor: pointer;
  user-select: none;
  transition: background var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease);
}
.tree-row:hover { background: var(--surface-2); --tint-ground: transparent; color: var(--text-2); }   /* 6.17:1 dark, 7.00:1 light */
/* The active row's band, one rule across the three rows that paint one: the
   quiet accent fill spans the row and starts at the row's own indent. Derived
   from .nav-item.active, her call 2026-09-01. It sits HERE, after
   `.tree-row:hover`, because the two tie at (0,2,0) and the last of the three
   member hover rules is the tree's: placed in the sidebar block as it shipped,
   a hovered selected tree row repainted its band grey (measured
   oklch(0.345 0.026 280) under the pointer). The sidebar's and the palette's
   hover rules sit above it in the file, so the one move covers all three. */
.nav-item.active,
.tree-row[aria-selected="true"],
.palette-row.active { background: var(--accent-quiet); }
/* The band is the shared active-row rule; this is the accent text on it.
   --accent-text on the quiet fill measures 5.78:1 over --surface-1 and 6.66:1
   over --bg, dark. */
.tree-row[aria-selected="true"] { color: var(--accent-text); }
/* light takes the shared remix; the light edge measures 3.90 and 3.52 */
/* the system's own disabled costume, and for the same reason the day cell takes
   it. The row's icon rides currentColor, so one declaration carries the node's
   type glyph with the label; the twist names --text-4 itself and is restated,
   or the chevron stays lit beside a dead row. --text-disabled measures 3.89:1 on
   --surface-1 and 4.50 on --bg dark, 4.23 and 3.76 light. */
.tree-row[aria-disabled="true"] { cursor: not-allowed; }
.tree-row[aria-disabled="true"] > .tree-twist { color: var(--text-disabled); }
.tree-row[aria-disabled="true"]:hover { background: none; }

/* EVERY row carries a twist, empty on a leaf: the box is the column, and without
   it a file's icon sits where its sibling folder's chevron does. --text-4 as a
   graphic on --surface-1 and --bg: 4.71 / 5.46 dark, 5.55 / 4.95 light. */
.tree-twist {
  display: grid;
  place-items: center;
  width: var(--tree-twist);
  height: var(--tree-twist);
  flex-shrink: 0;
  color: var(--text-4);
}
/* the slot states the size, or a `<use>` dropped in lays out at 300x150 */
.tree-twist > svg { width: 14px; height: 14px; transition: transform var(--dur-tint) var(--ease); }
.tree-row[aria-expanded="true"] > .tree-twist > svg { transform: rotate(90deg); }
/* a folder whose children have not arrived. The spinner takes the twist's own
   box, so the row does not resize while it waits. */
.tree-twist > .spinner { width: 12px; height: 12px; }

/* NO --icon-dim here, unlike `.nav-item svg`. In a nav row the icon repeats what
   the label already says; in a tree it carries the node's TYPE, and "src" does
   not say folder. As a graphic it therefore has to clear 3:1, and the shipped
   0.7 does not: a --text-3 icon at that alpha measures 2.91:1 on the light page
   and 2.81:1 on the light hover fill. At the row's own colour it holds 5.48 /
   6.34 dark and 5.92 / 5.28 light, and 6.17 / 7.00 on the hover surface. */
.tree-row > svg { width: 14px; height: 14px; flex-shrink: 0; }
.tree-label { flex: 1 1 auto; min-width: 0; }   /* truncates on tokens.css's .truncate */

/* a folder that expanded to nothing. It lines up with where its children would
   be: the row's own gutter, plus the twist column and its gap. --text-4 on
   --surface-1, 4.71:1 dark and 5.55:1 light. */
.tree-empty {
  padding-block: var(--space-1);
  padding-inline: calc(var(--space-2) + var(--tree-twist) + var(--space-2)) var(--space-2);
  font-size: var(--t-2);
  color: var(--text-4);
}

/* ---- Status legend ----
   The key that says what a dot means: a table's health column, a map's markers,
   a fleet grid. `.legend` names a CHART SERIES and reads --series; this names a
   LEVEL and reads the status tokens, so the two can never share a mark.

   `.status-key` is the mark itself and belongs in the cell as much as in the key
   - the same disc, the same token, in both places, which is the only thing that
   makes a key a key. `.status-cell` pairs it with its word, because a column of
   bare discs carries the level in colour alone.

   Every disc against the 3:1 graphics floor, on --surface-1 and on --bg:
   success 7.67 / 8.88 dark and 5.45 / 4.86 light, warning 8.35 / 9.67 and 5.90 /
   5.26, danger 5.16 / 5.97 and 6.01 / 5.36, accent 6.46 / 7.48 and 4.60 / 4.10,
   the idle ring 4.71 / 5.46 and 5.55 / 4.95.

   Nothing here is focusable. A legend that filters is a control, and
   `.legend-toggle` is already that control. */

.status-legend {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-5);
}
.status-legend-stack { flex-direction: column; align-items: stretch; gap: var(--space-2); }
.status-legend-item { min-width: 0; }
.status-legend-name { min-width: 0; }   /* truncates on tokens.css's .truncate */
.status-legend-count {
  font: 400 var(--t-2)/1.4 var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
  color: var(--text-4);   /* 4.71:1 on --surface-1 dark, 5.55:1 light */
  flex-shrink: 0;
}
/* only the stacked form has a far edge to pin the count to; in the wrapped row
   the gap is what separates one entry from the next */
.status-legend-stack .status-legend-count { margin-inline-start: auto; padding-inline-start: var(--space-3); }

.status-key {
  --status: var(--text-4);
  width: 8px;
  height: 8px;
  flex-shrink: 0;
  border-radius: var(--r-pill);
  background: var(--status);
  --tint-ground: transparent;
}
.status-key-success { --status: var(--success); }
.status-key-warning { --status: var(--warning); }
.status-key-danger { --status: var(--danger); }
.status-key-accent { --status: var(--accent); }
/* no reading yet, and a level with nothing in this view. Both hollow, because
   two fills at the same size differ only in hue and that is the one channel a
   status mark must not be alone in. */
.status-key-idle,
.status-legend-item.is-zero .status-key {
  background: none;
  box-shadow: inset 0 0 0 1.5px var(--status);
}
.status-legend-item.is-zero { color: var(--text-4); }

/* the cell form: the disc and its word, so the column never says the level in
   colour alone. The disc is aria-hidden; the word is the value. */
.status-cell { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }

/* ---- Full-page states ----
   Not found, the request that failed, and sign-in. `.empty-state` is a panel's
   own emptiness and sits inside it; these three own the viewport.

   No status code anywhere: the number is what the log is for, and the screen's
   job is the way out. Each one names it - an address to check, a retry, a
   reference to hand over, a field to fill.

   --page-state-chrome is what the shell subtracts. A state on a bare route
   leaves it at 0; one under the navbar sets it to --navbar-h. Without the knob a
   full-height shell under a 56px bar hangs 56px of scroll off a page with
   nothing to scroll to. */

.page-state {
  --page-state-chrome: 0px;
  min-height: calc(100vh - var(--page-state-chrome));
  min-height: calc(100dvh - var(--page-state-chrome));   /* vh lies under mobile browser chrome */
  display: grid;
  place-items: center;
  padding: var(--space-10) var(--space-6);
}
/* Her call 2026-09-01: these screens make the dot grid the subject, and the copy
   sits in a gap torn in it. The grid has to be visible for that to be true, and
   at the shipped resting alpha it is not: measured, a resting dot lands 5 sRGB
   levels off the page at 0.24 and 13 dark / 12 light at 0.60. The lift is on the
   root because one canvas paints the whole page; cursor.js reads it at boot and
   on a theme change. The spotlight keeps its 0.80 peak, so a raised floor
   shortens its travel rather than pushing it past the top.

   The selector is the ROUTE, never the class: `:has(.page-state)` also matches a
   catalogue page holding one as a specimen, and lifted the whole reference sheet's
   grid because of a 404 six levels deep inside a panel. Measured, the direct-child
   form matches the route and misses the specimen. */
html:has(> body > .page-state) { --dot-rest: 0.60; }
/* The same route keeps the flat page under the grid. The gap below is a
   page-toned shape, so over the sky it cut a dark cap out of the light above
   the copy; the grid is this screen's subject, and the sky is not. */
html:has(> body > .page-state) > body::after { content: none; }
/* The gap IS the design, and it is TORN rather than cut. A page-toned box with a
   radius paints a hard-edged rectangle across the canvas, which reads as a card
   laid on the grid: her words, "a gray square that blocks the view of background
   dots". The radial ramp holds the copy's own area at page tone and hands the
   grid back on the way out, so there is no edge to read. A stepped, grid-aligned
   edge was tried at the grid's own 40px pitch (--dot-gap) and reads no
   differently from the plain box. */
.page-state-gap {
  /* the far stop is the SAME colour at alpha 0 rather than the `transparent`
     keyword, which is rgba(0,0,0,0) and would ramp toward black. Both spellings
     measure the same here (31/32 dithered against a page at 32, one level, from
     Chrome's own alpha rounding), so the reason is the declaration, not a fix. */
  background: radial-gradient(ellipse 60% 60% at 50% 50%,
              var(--bg) 0 38%, oklch(from var(--bg) l c h / 0) 100%);
  padding: var(--space-16) var(--space-12);
}
.page-state-body {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-4);
  max-width: var(--page-narrow);
  text-align: center;
}
/* --t-7, a step under the two display sizes. --t-9 belongs to `.page-title`,
   which names a screen inside a product; this screen has no product around it.
   --text measures 11.67:1 on --bg and 10.09 on --surface-1 dark, 12.00 / 13.45
   light. */
.page-state-title {
  font: 600 var(--t-7)/1.15 var(--font-sans);
  letter-spacing: -0.025em;
  color: var(--text);
  text-wrap: balance;
}
@media (max-width: 768px) { .page-state-title { font-size: var(--t-6); } }
.page-state-text { font-size: var(--t-4); color: var(--text-3); }   /* 6.34:1 dark, 5.28:1 light on --bg */
.page-state-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
}
/* the reference the reader hands to whoever can act on it. Mono, because it is a
   machine string, and one step under the message, because it is not the message.
   5.46:1 dark, 4.95:1 light on --bg. */
.page-state-ref {
  font: 400 var(--t-2)/1.4 var(--font-mono);
  color: var(--text-4);
}

/* The sign-in card, on `.card`. 400px rather than --page-narrow: a two-field
   form on the prose measure reads as a page-wide box with two inputs stranded
   in it. */
.page-state-card {
  width: min(400px, 100%);
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  text-align: start;
}
.page-state-brand {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
}
/* the rule between the password form and the single-sign-on button. Two
   hairlines, so the word sits IN the line instead of on top of it. --surface-3
   rather than --line-soft: the chart block measured the two hairline tokens at
   1.04:1 and 1.18:1 on --surface-1 and neither of them paints; this one holds
   1.44:1 dark and 1.31:1 light. The word is what carries the meaning, at
   --text-4, 4.71:1 dark and 5.55:1 light on the card. */
.page-state-or {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  font-size: var(--t-2);
  color: var(--text-4);
}
.page-state-or::before,
.page-state-or::after {
  content: '';
  flex: 1 1 auto;
  height: 1px;
  background: var(--surface-3);
  --tint-ground: transparent;
}
.page-state-foot { font-size: var(--t-2); color: var(--text-3); text-align: center; }   /* 5.48 / 5.92 */

/* ---- Context menu ----
   A `.popover` anchored to the POINTER rather than to an element: right-click
   opens it where the click landed, arrow keys walk it, Escape closes it.

   THE ANCHOR RIDES `translate`, NEVER `transform`. `.popover`'s entrance
   animates transform, and an animation replaces the property outright, so a flip
   written as a transform is discarded for the length of the entrance and lands
   after it. `.chart-plate` centres itself the same way and for the same reason.

   The flip rule: the menu opens down and toward the end from the pointer, and
   flips the axis whose far edge would leave the viewport. Measure the panel
   after it is in the DOM and before it is shown - a panel behind `hidden` has no
   box at all. Flip, never clamp: a clamped menu covers the thing that was
   right-clicked, which is the one place the reader is looking.

   Rows are `.menu-item`, so their colours are the shipped ones: --text-2 on the
   panel measures 4.62:1 dark and 6.67:1 light, and a danger row, where --danger
   resolves to --glass-danger, 4.57:1 and 4.80:1. */

.ctxmenu {
  position: fixed;
  inset-block-start: var(--ctxmenu-y, 0px);
  inset-inline-start: var(--ctxmenu-x, 0px);
  /* over the modal scrim, because a context menu inside a dialog is still a
     context menu; under the toast stack, which is a different conversation */
  z-index: 550;
  min-width: 180px;
  max-width: min(320px, calc(100vw - 2 * var(--space-4)));
  translate: 0 0;
  transform-origin: top left;
}
.ctxmenu.ctxmenu-flip-x { translate: -100% 0; transform-origin: top right; }
/* a menu that opened upward arrives from below, which is where the pointer is.
   The tooltip's own pair of keyframes is exactly that move, so no third pair is
   written for it. */
.ctxmenu.ctxmenu-flip-y {
  translate: 0 -100%;
  transform-origin: bottom left;
  animation-name: tooltip-in-top;
}
.ctxmenu.ctxmenu-flip-x.ctxmenu-flip-y { translate: -100% -100%; transform-origin: bottom right; }
.ctxmenu.ctxmenu-flip-y.closing { animation-name: tooltip-out-top; }

/* the system's disabled costume again. On glass --text-disabled resolves to
   --glass-disabled, the one value still clearing 3:1 on the panel's worst case
   (3.27 dark, 3.37 light). Dark steps 0.100 of lightness under the --glass-dim
   every other dimmed thing on the panel collapses onto. The alpha it replaces
   measured 2.17 dark and 1.83 light there. */
.ctxmenu > .menu-item[aria-disabled="true"] { cursor: not-allowed; }
.ctxmenu > .menu-item[aria-disabled="true"]:hover { background: none; }
/* a checkable row holds its glyph column whether or not the mark is in it, so
   toggling one does not reflow the menu under the pointer */
.ctxmenu > .menu-item[role="menuitemcheckbox"] > svg { visibility: hidden; }
.ctxmenu > .menu-item[aria-checked="true"] > svg { visibility: visible; }
/* the slot states the size */
.ctxmenu > .menu-item > svg { width: 14px; height: 14px; flex-shrink: 0; }

/* ---- Route progress ----
   The hairline across the top of the page while a navigation is in flight.

   A LOADING INDICATOR, which is the one thing allowed to loop, and never
   `.progress`: that one is determinate and its width is the answer. This one
   says only that something is happening, so it carries no track, no value and
   nothing anyone could read a percentage off.

   The segment travels 1.4 viewport widths per pass, so the duration is 1.4 times
   --dur-cross, the ladder's own across-the-viewport step. Transform only: the
   bar is laid out full width and scaled, so no frame changes a layout, and the
   strip clips, so the segment parked past the far edge cannot widen the page.

   It is decoration. The route change is announced by the page's own live region:
   a progressbar with no value and no name announces nothing worth hearing. */

.route-progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 2px;   /* the ink's own thickness; a route bar is a hairline, not a track */
  /* over the navbar (100), the drawer (300) and the modal scrim (540); under the
     skip link, which is the keyboard user's way past all of it */
  z-index: 1050;
  overflow: hidden;
  pointer-events: none;
  /* THE REST STATE IS NOTHING PAINTED AND NOTHING RUNNING. The sweep used to sit
     on the base rule, so the element swept for the life of any page that mounted
     it - ambient motion, which this system bans outside three sanctioned effects
     - and a page could only stop it by keeping the `hidden` attribute on. The
     bar is a state, so a class carries it. */
  visibility: hidden;
}
.route-progress-bar {
  width: 100%;
  height: 100%;
  background: var(--accent);   /* 7.48:1 dark, 4.10:1 light on --bg, over the 3:1 floor */
  --tint-ground: transparent;
  transform-origin: left center;
}
.route-progress.is-loading { visibility: visible; }
.route-progress.is-loading .route-progress-bar {
  animation: route-sweep calc(var(--dur-cross) * 1.4) linear infinite;
}
@keyframes route-sweep {
  from { transform: translateX(-40%) scaleX(0.4); }
  to   { transform: translateX(100%) scaleX(0.4); }
}
/* the finish: the bar fills, holds a tint's worth, and the strip goes. A sweep
   that simply stops reads as a navigation that died on the way. */
.route-progress.is-done .route-progress-bar { animation: none; transform: none; }
.route-progress.is-done {
  /* the finish is still a visible state: the fade has to have something to fade,
     whether the controller swaps .is-loading for this class or adds it */
  visibility: visible;
  opacity: 0;
  transition: opacity var(--dur-out-near) var(--ease) var(--dur-tint);
}

/* ---- Copy chip ----
   A machine value and the affordance that copies it: a commit sha, a request id,
   a connection string, a key. `.code-copy` is this job inside a code block, where
   the button hides until the block is hovered; here the chip IS the affordance,
   so it never hides.

   The two forms differ on ONE property and it is the one that matters. Inline,
   the value truncates: a chip in a sentence or a toolbar has no width to wrap
   into and an unbroken 64-character string would widen the line. Filling a cell,
   it wraps at any character, which is what `.info-table td` and `.table-wrap td`
   already do with a machine string, and it is the only thing that works there:
   `max-width: 100%` resolves against the cell's used width, a table sizes that
   cell to its content, and the truncating chip therefore wins the argument and
   takes the page 136px wider at 390 (measured, before this split). */

.copy-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  max-width: 100%;
  min-width: 0;
  /* no end padding: the button's hover well fills the chip's end cap, and a
     2px sliver of chip around a hovered well read as a gap in the fill */
  padding-inline: var(--space-2) 0;
  background: var(--well);
  --tint-ground: transparent;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-1);
  vertical-align: middle;
}
/* the block form: a chip that fills its cell in an `.info-table` or a card row.
   The gap is the one `.copy-chip` already sets between its own children: as a
   wrapper around the label and the value this is a second flex container, and
   without it the two ran together as `commit3f8a2c1`. */
/* baseline, because the two children do NOT share a line height: the label is /1
   and the mono value /1.6, so a stretched row sits them on different baselines
   and the value rides low against its own label. */
.copy-chip-fill { display: flex; align-items: baseline; gap: var(--space-2); width: 100%; }
/* and the one property that has to flip with it */
.copy-chip-fill > .copy-chip-value {
  white-space: normal;
  overflow-wrap: anywhere;
  overflow: visible;
  text-overflow: clip;
}
/* what the value is, when the value itself does not say. 6.05:1 dark, 4.52:1
   light on --well. */
.copy-chip-label {
  font: 500 var(--t-1)/1 var(--font-sans);
  color: var(--text-4);
  flex-shrink: 0;
}
.copy-chip-value {
  font: 400 var(--t-2)/1.6 var(--font-mono);
  color: var(--text-2);   /* 9.33:1 dark, 6.80:1 light on --well */
  min-width: 0;   /* truncates on tokens.css's .truncate; .copy-chip-fill turns it off */
}
/* 24px and bare, the shape `.code-copy` already is: no border, no fill at rest,
   below every control size. --text-3 on --well measures 7.02:1 dark and 4.82:1
   light, and --text on the hover fill 8.56:1 and 11.28:1.
   The button takes the chip's full height and its end cap, so the hover well
   meets the chip's inner edges on all three sides: a fixed 24px height left a
   1px sliver above and below whenever the chip's row ran taller. Stretch, not
   a height, so a wrapping value grows the well with the chip; the floor holds
   the 24 for a chip whose text is shorter than that. The radius is the
   chip's own --r-1, her call, which sits a hair outside the 1px-border arc
   an exact concentric fit would take (9px); the chip does not clip, and the
   difference is sub-pixel at 1x. */
.copy-chip-btn {
  width: 24px;
  min-height: 24px;
  align-self: stretch;
  border-radius: var(--r-1);
  color: var(--text-3);
}
.copy-chip-btn > svg { width: 14px; height: 14px; }
/* the glyph swap rides the pop `.code-copy` already uses. --success on --well:
   9.84:1 dark, 4.44:1 light, over the 3:1 graphics floor. */
.copy-chip.is-copied .copy-chip-btn { color: var(--success); }
.copy-chip.is-copied .copy-chip-btn > svg { animation: check-pop var(--dur-near) var(--ease-out); }
/* the clipboard refused: no permission, or no secure context. The glyph says so
   and the value becomes one-click selectable, which is the fix the message
   names. --danger on --well: 6.62:1 dark, 4.89:1 light. */
.copy-chip.is-failed .copy-chip-btn { color: var(--danger); }
.copy-chip.is-failed .copy-chip-value { user-select: all; }

/* ---- Shortcut sheet ----
   Every shortcut the screen answers to, grouped, in the dialog `?` opens.
   `.modal` is the shell and `.label` is the group head, so the two lists a
   keyboard user reads - this one and the palette - name their groups alike.

   Two columns wherever they fit and one where they do not, off auto-fill rather
   than a breakpoint: the sheet's width is the modal's and the modal's is a
   min() of the viewport, so a column count keyed on the viewport would have to
   re-derive that min() and go stale the moment the modal's width moves.

   On the glass modal, worst case: the row at --text-2 measures 4.62:1 dark and
   6.67:1 light, and the group label, the separator and an unbound row, all of
   which resolve to --glass-dim, 4.62:1 and 4.73:1. */

.shortcuts {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: var(--space-5) var(--space-6);
}
.shortcuts-group { min-width: 0; }
/* `.label` carries the caps, the tracking and the colour; this only seats it */
.shortcuts-group-label { padding-block: var(--space-1); margin-bottom: var(--space-1); }
.shortcut-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  padding-block: 5px;
  font-size: var(--t-2);
  color: var(--text-2);
}
.shortcut-row + .shortcut-row { border-top: 1px solid var(--line-soft); }
.shortcut-name { min-width: 0; }
.shortcut-keys {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  flex-shrink: 0;
  white-space: nowrap;
}
/* the word between two chords: "then" for a sequence, "or" for an alternative.
   Sans, because it is prose standing in a row of keycaps. */
.shortcut-sep { font: 400 var(--t-1)/1 var(--font-sans); color: var(--text-4); }
/* a command with nothing bound to it. The row stays: the sheet is the list of
   what the screen can do, and a hole in it is the thing worth seeing. */
.shortcut-row.is-unset .shortcut-keys { font: 400 var(--t-1)/1 var(--font-sans); color: var(--text-4); }

/* ---- Scroll-edge fade ----
   The cue that a scrolling box holds more than it shows. The scrollers carry it
   themselves: `initScrollFade` writes the cue class onto every `pre`,
   `.table-wrap`, `.chart-scroll`, `.modal-body`, `.log-view`,
   `.pagination-pages`, `.sidebar-nav` and `.navbar-nav`; a consumer scroller
   opts in with `data-scroll-fade`, and floating panels (`.select-menu`,
   `.palette-list`) scroll bare. `no-scroll-fade` on any scroller is the one
   opt-out.

   IT IS A MASK, NOT A PAINTED RAMP. A painted ramp has to know the surface
   behind it, and the same scroller sits on a card, on the page and inside a
   glass panel; a mask carries no colour at all and is right on every one.

   THE BLEED IS LOAD-BEARING. A mask clips to its own painting area, the painting
   area is the border box, and a masked box therefore loses its shadow outright:
   measured on a 300x140 card, --elev-1 falls from 23 levels under and 11 beside
   to 0 and 0, and it falls there for a mask that fades nothing at all.
   `mask-clip: no-clip` lifts the clip, but the image then has to REACH past the
   box or the region outside it masks to transparent just the same (measured:
   no-clip with an unpadded no-repeat image is 0 and 0 again). So the image is
   sized 48px past the box on every side and carries an opaque plateau in the
   bleed with a hard stop at the box's own edge. 48 is `.select-menu`'s own bleed
   and for the same reason: --elev-float's widest coat is what has to clear it.
   On the shipped `.table-wrap` with the fade on, the shadow measures 24 levels
   under and 12 beside against 23 and 11 on the same page's unmasked one.

   THE FADE IS OFF AT REST. Both windows are 0 until a controller says the box
   can scroll that way, so a page with no controller paints no fade at all, and
   the cue can never claim there is more where there is nothing. Same contract
   `[data-reveal]` and `.chart.will-draw` run under.

   `@supports` gates the whole thing on `mask-clip`, so an engine without it
   keeps the shadow and loses only the cue, which is the resting state anyway.
   `-webkit-mask-*` is deliberately absent: this Chrome reports
   `CSS.supports('-webkit-mask-clip','no-clip')` false while the unprefixed form
   is true, so a prefixed spelling here is a dead declaration in the one engine
   measured and an unmeasured guess in the rest. */

@supports (mask-clip: no-clip) {
  .scroll-fade {
    --fade: var(--space-6);
    --fade-start: 0px;
    --fade-end: 0px;
    --fade-bleed: 48px;
    --fade-dir: to right;
    /* a mask reads alpha and nothing else. #000 and #0000 are the two ends of
       that alpha ramp, not a colour choice, which is why no token fits here. */
    --fade-mask: linear-gradient(var(--fade-dir),
      #000 0 var(--fade-bleed),
      #0000 var(--fade-bleed),
      #000 calc(var(--fade-bleed) + var(--fade-start)),
      #000 calc(100% - var(--fade-bleed) - var(--fade-end)),
      #0000 calc(100% - var(--fade-bleed)),
      #000 calc(100% - var(--fade-bleed)) 100%);
    mask-image: var(--fade-mask);
    mask-size: calc(100% + 2 * var(--fade-bleed)) calc(100% + 2 * var(--fade-bleed));
    mask-position: calc(-1 * var(--fade-bleed)) calc(-1 * var(--fade-bleed));
    mask-repeat: no-repeat;
    mask-clip: no-clip;
  }
  /* the block axis, written by the controller when the element scrolls down:
     `.modal-body`, `.log-view`, the sheet's own column. It rides on `.scroll-fade`,
     which owns the mask, and turns it: the axis is the only thing that differs,
     so it is the only thing this rule says. */
  .scroll-fade-y { --fade-dir: to bottom; }
  .scroll-fade.has-more-start { --fade-start: var(--fade); }
  .scroll-fade.has-more-end { --fade-end: var(--fade); }
}
/* The opt-out: one class on any scroller suppresses the cue, so a consumer never
   has to unset the four custom properties the cue rule sets. The controller
   skips it; this kills the mask for any element that carries both. */
.no-scroll-fade { mask-image: none; }

/* The cue IS a transparency, so a viewer who asked for none gets none of it, and
   a hairline takes the job instead. Dropping the mask alone leaves that reader
   with nothing: this platform paints overlay scrollbars, so the nav's right edge
   measured ONE unique colour at rest, after a scroll, and 1.5s later, and the
   gutter is 0px. An inset shadow holds station on the padding box while the
   content scrolls under it, so it costs no layout and no second element.
   The hairline follows the axis the element actually scrolls: the block edges
   for a vertical scroller, the inline edges for a horizontal one. */
@media (prefers-reduced-transparency: reduce) {
  .scroll-fade { mask-image: none; }
  .scroll-fade.has-more-start { box-shadow: inset 1px 0 0 var(--line); }
  .scroll-fade.has-more-end { box-shadow: inset -1px 0 0 var(--line); }
  .scroll-fade.has-more-start.has-more-end {
    box-shadow: inset 1px 0 0 var(--line), inset -1px 0 0 var(--line);
  }
  .scroll-fade-y.has-more-start { box-shadow: inset 0 1px 0 var(--line); }
  .scroll-fade-y.has-more-end { box-shadow: inset 0 -1px 0 var(--line); }
  .scroll-fade-y.has-more-start.has-more-end {
    box-shadow: inset 0 1px 0 var(--line), inset 0 -1px 0 var(--line);
  }
  /* a scroller that carries elevation restates it beside the hairline: the
     generic rules above replace `box-shadow` whole, so a scrolling `.table-wrap`
     spent its three --elev-1 coats on one inset line. At (0,3,0) these win on
     specificity whatever the order; the scrollers with no shadow of their own
     keep the generic form. */
  .table-wrap.scroll-fade.has-more-start {
    box-shadow: var(--elev-1), inset 1px 0 0 var(--line);
  }
  .table-wrap.scroll-fade.has-more-end {
    box-shadow: var(--elev-1), inset -1px 0 0 var(--line);
  }
  .table-wrap.scroll-fade.has-more-start.has-more-end {
    box-shadow: var(--elev-1), inset 1px 0 0 var(--line), inset -1px 0 0 var(--line);
  }
  /* the second scroller in the controller's list that carries its own
     elevation. It scrolls on the block axis past its own max-height, so it
     takes the -y forms, and it floats, so the coat it restates is --elev-float:
     without this a menu long enough to scroll paints no elevation at all over
     the card behind it. */
  .select-menu.scroll-fade-y.has-more-start {
    box-shadow: var(--elev-float), inset 0 1px 0 var(--line);
  }
  .select-menu.scroll-fade-y.has-more-end {
    box-shadow: var(--elev-float), inset 0 -1px 0 var(--line);
  }
  .select-menu.scroll-fade-y.has-more-start.has-more-end {
    box-shadow: var(--elev-float), inset 0 1px 0 var(--line), inset 0 -1px 0 var(--line);
  }
}

/* ---- Coarse pointers ----
   Rows built from `.menu-item` already take the floor from the shipped block.
   These three do not: the day cell is its own grid module, the tree row is 30px
   by default, and the copy button is a 24px glyph, the same shape `.code-copy`
   grows from. */
@media (pointer: coarse) {
  /* The cell is what the panel's width IS: seven of them plus the calendar's
     own 2 * --space-3 gutter. So the panel's clamp is expressed here, on the
     one number every part of the grid derives from, rather than on a box that
     `.popover`'s `overflow: hidden` would then cut a date column out of.
     (100vw - 64px) / 7 is that clamp read back: 64 is the panel's own
     2 * --space-5 bound plus the calendar gutter. It bites under 372px, where
     seven 44px cells plus the gutters fit no viewport - measured 44.0 at 373
     and 374, 42.3 at 360, 36.6 at 320 - so every phone at 373 and up keeps the
     floor whole, and under it the cell keeps its 44px HEIGHT and gives up
     width, which is the only thing left to give. */
  .daterange { --dr-cell: min(var(--ctl-lg), calc((100vw - 64px) / 7)); }
  /* the token carries both axes, and only the width may give: restated at the
     base rule's own (0,1,0), later in the file, so the row keeps the floor */
  .daterange-day { height: var(--ctl-lg); }
  .tree-row { min-height: var(--ctl-lg); }
}

/* ---- Reduced motion ----
   Every restatement sits at its rule's own specificity or higher, and after it.
   The twist's rotation and the day cell's state are VALUES, not decoration, so
   they stay and only the travel to them goes. The route bar has to keep saying
   something, so its sweep becomes a fade on the same tempo rather than nothing:
   the ladder's rule is that reduced motion replaces. */
@media (prefers-reduced-motion: reduce) {
  .tree-twist > svg { transition: none; }
  /* the flipped menu's own (0,2,0) and (0,3,0) outrank the `.popover` fades */
  .ctxmenu.ctxmenu-flip-y { animation-name: fade-in; }
  .ctxmenu.ctxmenu-flip-y.closing { animation-name: fade-out; }
  .copy-chip.is-copied .copy-chip-btn > svg { animation: none; }
  /* keyed on the running state like the sweep it replaces, and at that rule's own
     (0,2,0), or reduce would start the hold on a bar that is at rest */
  .route-progress.is-loading .route-progress-bar {
    transform: none;
    animation: route-hold calc(var(--dur-cross) * 1.4) var(--ease) infinite;
  }
  .route-progress.is-done .route-progress-bar { animation: none; opacity: 1; }
}
@keyframes route-hold { 50% { opacity: 0.4; } }

/* ---- Forms and tables: the six a management dashboard needs ----
   Combobox, multi-select, dropzone, input group, button group and split button,
   table extras. Nothing here is a new surface, a new text level or a new shadow.

   THE TWO EXTENSIONS OF `.select` ARE EXTENSIONS, NOT RIVALS. `.combobox` and
   `.select-multi` keep the shipped root, the shipped trigger, the shipped panel
   and the shipped fold: `--fold` stays the only clock, `--seal` still shuts the
   button's mouth, `--lift` still raises its fill. A second closing schedule
   beside that one is the defect the select round was spent removing.

   THE COMBOBOX'S TRIGGER IS THE `<input>` ITSELF, carrying `.input
   .select-trigger` exactly as the button did. The caret and the clear move out
   to `.select` and sit over the field on insets, the way `.input-icon` and
   `.input-suffix` already do. The rejected alternative was a wrapping div with
   the field inside it: `:focus` then never fires on the trigger, so every one of
   `.input`'s own states has to be re-derived on `:focus-within`, and each
   restatement has to clear `.input:hover:not(:focus):not(.input-invalid):not(
   [aria-invalid="true"])`, which is (0,5,0). Four rules at (0,5,0) to reproduce
   what the element already does for free.

   THE LATTE RAMP IS WHY SEVERAL FILLS HERE RISE RATHER THAN SINK. Light --well
   is 0.925 and light --surface-2 is 0.935: a --surface-2 addon on a --well field
   measures 1.03:1 and is not there. --surface-1 is 0.995, so an addon, a chip
   and a progress track that RISE off the sunken field measure 1.28:1 dark and
   1.23:1 light, the same step a card takes over a well band. Every "its own
   surface" in this block is that step.

   ONE NEUTRAL HAIRLINE, `color-mix(in srgb, var(--text-4) 40%, var(--tint-ground))`,
   for the dropzone's dash, the sticky column's divider and the inline field's
   hover edge. The opaque neutrals cannot do this job: --line measures 1.11:1 on
   the light well and --surface-3 1.06:1, so both vanish in Latte. The mix tracks
   whatever it sits on, the timeline rail's construction at a heavier alpha:
   measured 1.92 / 1.81 / 2.03 / 2.00 / 1.90 dark and 1.77 / 1.69 / 1.68 / 1.72 /
   1.70 light on --surface-1, --surface-2, --well, --bg and the row-selection
   tint. Against the chart grid's shipped 1.45:1, all ten read.

   Every ratio in this block was measured with tools/contrast.mjs. */

/* ============================================================
   Combobox: `.select` with a query field
   ============================================================ */

/* The field replaces the value label; everything else is the shipped select.
   `role="combobox"` goes on the input, `aria-controls` on the panel,
   `aria-activedescendant` on the arrow-key row. */
.combobox > .select-trigger {
  /* `.select-trigger` is a flex row because it was a button holding two children.
     On an <input> the flex box fights the editing shadow tree, so the trigger
     goes back to a block and the two children sit over it on insets. */
  display: block;
  /* the caret's slot: 12px inset plus its 14px box plus 6px of air */
  padding-inline-end: 32px;
}
/* `.input:disabled, .select-trigger:disabled` sets `not-allowed` at the same
   (0,2,0) and earlier in the file, so a bare `cursor: text` here won it back on
   source order: measured, a disabled combobox painted `text` where the disabled
   select, input and button all painted `not-allowed`. The ring reads `cursor`
   and nothing else, so that trigger also drew the normal ring rather than the
   blocked one. */
.combobox > .select-trigger:not(:disabled) { cursor: text; }
/* the clear reserves its slot from the moment it is in the DOM, `hidden` or not,
   so the query text does not shift sideways the frame the first character lands */
.combobox:has(> .select-clear) > .select-trigger { padding-inline-end: 56px; }

/* `.select-trigger:hover .select-caret` is a descendant rule and the caret is no
   longer a descendant, so the hover tint is restated on the general sibling */
.combobox > .select-trigger:hover ~ .select-caret { color: var(--text-3); }

/* The caret keeps its own `transform: rotate(180deg)` open state, so the
   centring rides `translate` instead: a transform here would be replaced by
   that rotation, which is the trap `.chart-plate` already names. */
.combobox > :is(.select-caret, .select-clear) {
  position: absolute;
  inset-block-start: 50%;
  translate: 0 -50%;
}
.combobox > .select-caret {
  inset-inline-end: 12px;   /* `select.input`'s own arrow inset */
  pointer-events: none;
}
.combobox > .select-clear { inset-inline-end: 28px; }
/* the shipped `.input:disabled` costume covers the field; the two furniture
   pieces sit outside it and would stay lit. Both are graphics at --text-4, so
   they take the same step the value does: --text-disabled measures 4.99:1 on
   --well dark and 3.44 light, over the 3:1 graphics floor. */
.combobox:has(> .select-trigger:disabled) > :is(.select-caret, .select-clear) {
  color: var(--text-disabled);
  pointer-events: none;
}

/* ---- The clear, shared with the multi-select ----
   --text-4 measures 6.07:1 dark and 4.52:1 light on --well and 4.73 / 5.55 on
   the --surface-1 the trigger lifts to while the panel is open, both over the
   3:1 graphics floor. Hover takes --text on --surface-2: 8.56 / 11.28. */
.select-clear {
  position: relative;
  width: 22px;
  height: 22px;
  border-radius: var(--r-pill);
  color: var(--text-4);
}
.select-clear > svg { width: 12px; height: 12px; }

/* ---- The panel's non-row states ----
   No match, loading and "type to search" are all `.palette-empty`, the system's
   row that names the fix. Only the gutter changes, from the palette's own 14px
   to the 8 `.select-menu .menu-item` already uses inside the panel's 4px, so
   the text column lines up with the options above it. */
.select-menu .palette-empty {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding-inline: 8px;
}

/* The matched substring. `<mark>` carries a UA yellow that nothing in this
   system would survive, and colour is the wrong device here: an option row is
   already three colours deep between its rest, hover and selected fills. Weight
   carries the match and costs no measurement, because the text keeps the colour
   `.menu-item` was measured with on every one of those fills. */
.select-menu mark {
  background: none;
  color: inherit;
  font-weight: 600;
}

/* The committed value's mark. Colour alone does not carry a selection: the
   accent fill `.menu-item[aria-selected="true"]` paints is the tint, and this is
   the glyph. --accent-text on that fill measures 5.80:1 dark and 5.50:1 light
   opaque, and the glass block's own 5.95 / 4.89 on the panel, over the 3:1
   graphics floor either way. */
.select-check {
  width: 14px;
  height: 14px;
  flex-shrink: 0;
  margin-inline-start: auto;
  opacity: 0;
}
.select-menu .menu-item[aria-selected="true"] .select-check { opacity: 1; }

/* Nothing chosen yet. --text-placeholder measures 4.99:1 dark and 4.52:1
   light on the --well trigger. */
.select-placeholder { color: var(--text-placeholder); }

/* ============================================================
   Multi-select: the same control returning several values
   ============================================================ */

/* The trigger is a `<div tabindex="0" role="combobox">`, not an <input> and not
   a <button>: it holds the chips, and each chip holds its own remove control.
   Being focusable in its own right, it keeps `.select-trigger:focus` and every
   other shipped state without a single restatement. */
.select-multi > .select-trigger {
  /* NAMED DEVIATION from the height ladder: the trigger grows with its content.
     --ctl is the floor, not the height, because the chips are the value. */
  height: auto;
  min-height: var(--ctl);
  padding-block: 4px;
  cursor: pointer;
}
/* A div cannot be `:disabled`, so the state is `aria-disabled` and the costume
   is `.input:disabled`'s own. The trigger stays in the tab order, which is what
   aria-disabled is for: a control that announces why it cannot act. */
.select-multi > .select-trigger[aria-disabled="true"] {
  color: var(--text-disabled);
  cursor: not-allowed;
  pointer-events: none;
}
/* A chip declares its own fill and its own two text colours, so the trigger's
   colour reaches none of them: without these three the row of chips stays fully
   lit inside a dead control, which is the exact failure the alpha hid. The chip
   keeps its fill, since the chips ARE the value and a value that vanishes reads
   as an empty field; --text-disabled holds 3.29:1 on the lifted mix dark and
   3.54 light. */
.select-multi > .select-trigger[aria-disabled="true"] .select-chip,
.select-multi > .select-trigger[aria-disabled="true"] .select-chip > .close,
.select-multi > .select-trigger[aria-disabled="true"] .select-chip-more {
  color: var(--text-disabled);
}
/* The chips wrap inside their own box, so the clear and the caret hold their
   place at the end of the row whatever the chip block does. */
.select-chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
  flex: 1 1 auto;
  min-width: 0;
}

/* A chip is `.tag.filter-chip.select-chip`: `.tag` gives the pill, `.filter-chip`
   gives the entrance, the end padding and the coarse floor, and this gives the
   fill, because `.tag-default`'s --surface-2 measures 1.03:1 on the light well.

   THE FILL RIDES --lift, THE SELECT'S OWN PROGRESS. At rest the chip is
   --surface-1 on the --well trigger, 1.28:1 dark and 1.23:1 light; while the
   panel is open the trigger has lifted to --surface-1 itself, so the chip lifts
   with it to --surface-2, 1.18 / 1.19. One number for both, and no second clock.
   The fallback keeps a chip used outside a `.select` visible: --lift is not
   registered, so a bare var() there is invalid at computed-value time and the
   whole color-mix would fall back to transparent. */
.select-chip {
  background: color-mix(in oklab, var(--surface-1), var(--surface-2) calc(var(--lift, 0) * 100%));
  --tint-ground: transparent;
  color: var(--text-2);   /* 7.30:1 dark and 8.36:1 light at rest, 6.17 / 7.00 open */
  max-width: 100%;
}
.select-chip > .close { color: var(--text-3); }   /* 5.49 / 5.92 at rest, 4.65 / 4.96 open */
/* THE remove hover, declared here rather than with `.close` in the
   one-remove-control section because this rest rule ties it at (0,2,0) and a
   state must not lose to a rest: the order is the same treatment
   `.menu-item.active` got against `.menu-item:hover`. The chip's own
   (0,3,0) hover carries the same costume at chip scale either way, so this
   is the belt to that braces, and the braces reach the select chip because
   every select chip is spelled `tag filter-chip select-chip`: derive that set
   from the markup rather than carrying its count, which differs per tree.
   Measured 2026-09-07: with this rest colour planted BELOW the hover, winning
   the (0,2,0) tie, a hovered select chip still paints --danger through the
   filter-chip rule. A select chip written without `.filter-chip` puts it back
   on order alone. Numbers and the light-theme record live with the skeleton
   rules in the one-remove-control section. */
.close:hover {
  color: var(--danger);
  background: var(--well);
  --tint-ground: transparent;
}
/* a glass panel is the one ground where --well loses and --surface-2 reads:
  1.28:1 dark over the black plate and 1.34 over the white, against --well's
  1.18. --danger is --glass-danger inside these panels, and holds 6.11:1 on
  the fill. Light is one of the three grounds no grey clears 1.2 on:
  --surface-2 reads 1.05 / 1.19, the same fill the neutral hover already
  painted there, and --surface-1 clears the black plate at 1.25 only to read
  1.00 on the white one. */
.toast .close:hover,
.modal .close:hover { background: var(--surface-2); --tint-ground: transparent; }

/* One line, the remainder folded into a count the controller writes. Same
   contract `.tabs-boxed-wrap.has-ink` runs under: the class is added at runtime,
   and without it the trigger keeps every chip and grows. */
.select-compact .select-chips { flex-wrap: nowrap; overflow: hidden; }
.select-compact .select-chip { flex-shrink: 0; }
.select-chip-more { flex-shrink: 0; font-family: var(--font-mono); }

/* ============================================================
   Dropzone
   ============================================================ */

/* THE NATIVE INPUT IS THE CONTROL. It carries the focus, the accessible name it
   takes from the label around it, the picker and the platform's own drop
   handling; the zone is the label's paint. The input covers the label at
   `opacity: 0`, and its `::file-selector-button` covers the input, because a
   file input only opens the picker from its button and a click anywhere else in
   its box does nothing at all.

   The dash is the one drawn line in this system that is not separation: it says
   "a file goes here". The lede says the same in words, so the dash is
   scaffolding rather than a graphical object under 1.4.11, which is the same
   reading the chart gridlines ship under. It still has to be visible: the
   neutral hairline measures 2.03:1 dark and 1.68:1 light on --well, against
   --line's own 1.52 / 1.11 on the same ground. */
.dropzone { display: flex; flex-direction: column; gap: var(--space-3); }

.dropzone-target {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  padding: var(--space-6) var(--space-5);
  text-align: center;
  border-radius: var(--r-2);
  background: var(--well);
  --tint-ground: transparent;
  border: 1px dashed color-mix(in srgb, var(--text-4) 40%, var(--tint-ground));
  cursor: pointer;
  transition: background var(--dur-tint) var(--ease), border-color var(--dur-tint) var(--ease);
}
.dropzone-target > svg {
  width: 24px;
  height: 24px;
  margin-bottom: var(--space-2);
  color: var(--text-4);   /* 6.07:1 dark, 4.52:1 light on --well */
}
.dropzone-lede {
  font: 500 var(--t-3)/1.4 var(--font-sans);
  color: var(--text-2);   /* 9.36:1 dark, 6.79:1 light on --well */
}
/* the word that names the picker. --accent-text measures 10.15:1 dark on --well;
   light takes the shared remix, 5.23:1 there. */
.dropzone-browse { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; }
.dropzone-hint {
  font-size: var(--t-2);
  color: var(--text-4);   /* 6.07:1 dark, 4.52:1 light on --well */
}
/* every part of the zone names its own colour, so the disabled rule above reaches
   none of them on its own. The browse word loses its accent with the rest: it
   promises a picker that will not open. --text-disabled on --well: 4.99:1 dark,
   3.44 light. */
.dropzone:has(.dropzone-input:disabled) > .dropzone-target :is(svg, .dropzone-lede, .dropzone-browse, .dropzone-hint) {
  color: var(--text-disabled);
}

/* 1px rather than stretched: a native file input covering the zone paints the
   platform pointer through the ring's `cursor: none`, and its UA shadow tree is
   out of the page's reach. It keeps a box so it stays focusable, the label opens
   the picker, and `initDropzone` gives the zone's drop its files. */
.dropzone-input {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}
/* The field's own ring is invisible under opacity 0, so the zone wears it. Same
   outline and the same 3px offset tokens.css ships, on the box that paints. */
.dropzone:has(.dropzone-input:focus-visible) > .dropzone-target {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
.dropzone-target:hover { border-color: var(--text-4); }   /* 6.07 / 4.52 on --well, over the 3:1 floor */

/* Dragover. The fill is 10 percent accent laid down opaque over the well, a
   1.20:1 step dark and 1.13:1 light, which is the card-on-page step the eye
   already tracks. On it: the dash and the icon at --accent measure 6.94:1 dark
   and 3.31:1 light against the 3:1 graphics floor, the lede 8.47 / 4.64 (the
   light label taking the same tag remix `.btn-primary` does), the hint at
   --text-2 7.81 / 6.02. --text-4 falls to 3.91 on the light fill, so the hint
   lifts a step for this state only. */
.dropzone.is-over > .dropzone-target {
  background: color-mix(in srgb, var(--accent) 10%, var(--well));
  --tint-ground: transparent;
  border-color: var(--accent);
  border-style: dashed;
}
.dropzone.is-over > .dropzone-target > svg { color: var(--accent); }
.dropzone.is-over .dropzone-lede { color: var(--accent-text); }   /* light takes the shared remix, 4.64:1 on this fill */
.dropzone.is-over .dropzone-hint { color: var(--text-2); }

/* Nothing in the drop was acceptable. 8 percent danger over the well, a 1.13:1
   step in both themes; the dash and the icon at --danger measure 5.90:1 dark and
   4.33:1 light, the lede at --text-2 8.31 / 6.01. The hint takes the same lift
   the dragover state takes, and for the same reason the comment above gives:
   --text-4 falls to 3.99:1 on this fill in Latte, so it goes to --text-2 at 8.31
   dark and 6.01 light. The two states fail by the same magnitude and only one
   of them shipped the lift. */
.dropzone.is-rejected > .dropzone-target {
  background: color-mix(in srgb, var(--danger) 8%, var(--well));
  --tint-ground: transparent;
  border-color: var(--danger);
}
.dropzone.is-rejected > .dropzone-target > svg { color: var(--danger); }
.dropzone.is-rejected .dropzone-hint { color: var(--text-2); }

/* The state is read off the native control, so the costume cannot drift from
   what the picker actually does. */
.dropzone:has(.dropzone-input:disabled) > .dropzone-target {
  color: var(--text-disabled);
  /* the dashed edge is DERIVED from the same token as the copy, so quieting the
     copy quiets the edge with it and the zone keeps one construction */
  border-color: color-mix(in srgb, var(--text-disabled) 40%, var(--tint-ground));
  cursor: not-allowed;
}

/* ---- What arrived ----
   Rows sink to --well like the zone above them, so the whole control reads as
   one area and every ratio in a row is measured on one ground. */
.dropzone-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.dropzone-file {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  background: var(--well);
  --tint-ground: transparent;
  border-radius: var(--r-1);
  /* the entrance carries no fill mode, so a page with no controller paints the
     settled row and the row is never hidden behind a class */
  animation: panel-in var(--dur-near) var(--ease-out);
}
.dropzone-file-icon { display: flex; color: var(--text-4); }   /* 6.07 / 4.52 on --well */
.dropzone-file-icon > svg { width: 16px; height: 16px; }
.dropzone-file.is-done > .dropzone-file-icon { color: var(--success); }   /* 9.87 / 4.43 */
.dropzone-file.is-error > .dropzone-file-icon { color: var(--danger); }   /* 6.64 / 4.89 */

.dropzone-file-body { min-width: 0; }
.dropzone-file-name {
  font-size: var(--t-3);
  color: var(--text);   /* 12.98:1 dark, 10.95:1 light on --well */
}
.dropzone-file-meta {
  font: 400 var(--t-1)/1.4 var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--text-3);   /* 7.05:1 dark, 4.82:1 light on --well */
  margin-top: 2px;
  overflow-wrap: anywhere;
}
.dropzone-file.is-error .dropzone-file-meta {
  font-family: var(--font-sans);   /* a reason is prose, not a readout */
  color: var(--danger);   /* 6.64:1 dark, 4.89:1 light on --well */
}
/* The shipped `.progress` track is --surface-2, which measures 1.03:1 on the
   light well and is not there. --surface-1 rises off the row instead: 1.28:1
   dark and 1.23:1 light, with --accent on it at 6.48 / 4.60 over the 3:1 floor. */
.dropzone-file .progress {
  background: var(--surface-1);
  --tint-ground: transparent;
  margin-top: var(--space-2);
}
/* The remove is `.close`, the one remove control with the coarse floor already
   on it. The row's ground is --well, the one ground where the well itself is
   invisible: --well on --well measures 1.00:1 in both themes. The fill rises
   instead, the move this block already makes for the file's progress track:
   --surface-1 reads 1.28:1 dark and 1.23:1 light on the row, and --danger on
   it 5.17:1 dark and 6.00:1 light. */
.dropzone-file > .close:hover {
  background: var(--surface-1);
  --tint-ground: transparent;
  color: var(--danger);
}

/* ============================================================
   Input group: prefix and suffix boxes, and the number stepper
   ============================================================ */

/* `.input-suffix` paints a unit INSIDE the field and is not a control. An addon
   is its own surface beside the field: a protocol, a domain, a currency, a
   copy button. The field sinks to --well and the addon rises to --surface-1,
   1.28:1 dark and 1.23:1 light apart; --surface-2 would be 1.03 in Latte.

   The seam is one border, not two: every member after the first pulls back a
   pixel and the focused one rises above its neighbours. The ring belongs to the
   GROUP, not to the field, or a 3px halo paints straight across the addon it
   was joined to. */
.input-group {
  display: flex;
  align-items: stretch;
  border-radius: var(--r-1);
  --ring-color: var(--accent-line);
}
/* the corners are the ladder's: --r-1 outside, square inside. The two end radii
   are the same pair every joined group takes, so they are declared once for all
   three in the button-group block. */
.input-group > * {
  position: relative;
  border-radius: 0;
}
.input-group > * + * { margin-inline-start: -1px; }
.input-group > .input { flex: 1 1 auto; min-width: 0; }
/* the field keeps its accent border and gives up its own halo to the group */
.input-group > .input:focus { --ring: 0px; z-index: 2; }
.input-group:has(> .input:focus) { box-shadow: 0 0 0 3px var(--ring-color); }
.input-group:has(> .input:is(.input-invalid, [aria-invalid="true"])) {
  --ring-color: color-mix(in srgb, var(--danger) 30%, var(--glass-ground));
}
/* a disabled field takes its addons with it: `.input:disabled` dims only itself.
   The addon's own icon names --text-4, so it is restated or it stays lit inside a
   dead group. --text-disabled on --surface-1: 3.89:1 dark, 4.23 light. */
.input-group:has(> .input:disabled) > :is(.input-addon, .input-step),
.input-group:has(> .input:disabled) > .input-addon > svg {
  color: var(--text-disabled);
  pointer-events: none;
}

.input-addon {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
  padding: 0 12px;   /* `.input`'s own inline padding, so the two texts line up */
  background: var(--surface-1);
  --tint-ground: transparent;
  border: 1px solid var(--line);
  color: var(--text-3);   /* 5.49:1 dark, 5.92:1 light on --surface-1 */
  font: 400 var(--t-3)/1.4 var(--font-sans);
  white-space: nowrap;
}
/* a unit, a protocol or a hash is data; a word beside a field is not, so the
   mono is opt-in rather than the addon's default */
.input-addon-data { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.input-addon > svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--text-4); }   /* 4.73 / 5.55 */

/* ---- Number stepper ----
   The buttons are the group's own members, so they share the field's height at
   every breakpoint by stretching rather than by a second ladder. The native
   spinners go: they are UA chrome, they ignore the theme, and they put a second
   pair of arrows over the ring cursor, which is why `.select` exists at all. */
.input-step {
  display: grid;
  place-items: center;
  width: var(--ctl);
  flex-shrink: 0;
  padding: 0;
  background: var(--surface-1);
  --tint-ground: transparent;
  border: 1px solid var(--line);
  color: var(--text-3);   /* 5.49:1 dark, 5.92:1 light on --surface-1, over the 3:1 floor */
  cursor: pointer;
  transition: background var(--dur-tint) var(--ease), color var(--dur-tint) var(--ease);
}
.input-step:disabled { color: var(--text-disabled); cursor: not-allowed; pointer-events: none; }
.input-step > svg { width: 14px; height: 14px; }
/* the group clips nothing, so the ring can sit outside; the offset stays the
   page's, and the raised z-index keeps it over both neighbours */
.input-step:focus-visible { z-index: 2; }

/* The value centres because the two controls flank it. The mono face and the
   spinners-off are the number field's own, declared with `.input` above.
   --text measures 12.98:1 dark and 10.95:1 light on --well. */
.input-group-num > .input { text-align: center; }

/* ============================================================
   Button group and split button
   ============================================================ */

/* The corners are the ladder's --r-1 outside and square inside; no new number
   appears anywhere in this block. Members shrink rather than scroll, so a group
   can never widen the page: put a `.truncate` span inside a label that has to
   give way. Past four segments the control is a `.select`. */
.btn-group {
  display: inline-flex;
  max-width: 100%;
  border-radius: var(--r-1);
}
.btn-group > .btn,
.btn-split > .btn {
  position: relative;
  border-radius: 0;
  flex: 0 1 auto;
  min-width: 0;
}
/* the run's two end radii, for all three joined groups. Logical, so a
   right-to-left page rounds the end the reader's eye actually starts at. It has
   to sit AFTER the `border-radius: 0` resets above: `.btn-split > .btn` ties
   `.btn-split > .btn-split-toggle` at (0,2,0), so an earlier placement squares
   the split button's own outer end. */
.input-group > :first-child,
.btn-group > .btn:first-child,
.btn-split > .btn:first-child {
  border-start-start-radius: var(--r-1);
  border-end-start-radius: var(--r-1);
}
.input-group > :last-child,
.btn-group > .btn:last-child,
.btn-split > .btn-split-toggle {
  border-start-end-radius: var(--r-1);
  border-end-end-radius: var(--r-1);
}
.btn-group > .btn + .btn,
.btn-split > .btn + .btn { margin-inline-start: -1px; }
/* the pixel the seam gave up comes back under the pointer and under focus, so a
   lit border is never half-covered by the neighbour that follows it */
.btn-group > .btn:hover,
.btn-group > .btn:focus-visible,
.btn-split > .btn:hover,
.btn-split > .btn:focus-visible { z-index: 2; }

/* The pressed segment takes the outlined-primary pair, measured 6.67:1 dark on
   --bg and 5.80 on --surface-1, with the light label on the same tag remix
   `.btn-primary` uses at 4.92 and 5.50.

   IT KEEPS THAT COSTUME UNDER THE POINTER, written one class deeper than
   `.btn-secondary:hover` so hover cannot repaint it. That is not only the
   `.row-selected:hover` precedent: `.btn-primary`'s own hover fill, 24 percent
   accent, lands its light label at 4.20:1 (measured), so borrowing it here would
   ship a state under the floor.

   THE FILL IS THE WHOLE MARK. The group's contract is joined segments sharing one
   outline, so a pressed segment taking `--accent-line` on all four sides drew a
   second box inside the first: the accent edge lay over the group's own hairline
   along the top and bottom, doubled every seam it met, and put a square corner
   against the group's rounded one. Same one-mark-per-state reading as the retired
   row capsule. */
.btn-group > .btn[aria-pressed="true"],
.btn-group > .btn[aria-pressed="true"]:hover {
  background: var(--accent-quiet);
  color: var(--accent-text);
}
/* THE light accent-label remix, and every site needing one takes it from here.
   --accent-text alone falls to 4.35:1 on the light quiet fill, the same drop
   `.btn-primary` takes. This mix holds 4.92:1 on --accent-quiet over --bg, 5.50
   over --surface-1, 4.65 over --surface-2 and 4.52 over --well, 5.23 on a plain
   --well, and 4.64 on the dropzone's 10 percent accent fill: every ground it
   lands on clears 4.5. `.btn-primary`'s own light label is NOT a member and
   mixes 50/50, because the fill is the light thing IT mixes toward; its rule
   measures that at its own site. `.tag-info` is not a member either: it belongs
   to the tag family, where each variant remixes its OWN semantic token, and only
   --info happens to resolve to the accent.

   ITS PLACEMENT IS FREE WHILE BOTH ITS `.btn-group` SELECTORS TRAVEL TOGETHER,
   and the tie this comment used to claim is not the one that exists. Every
   selector here carries `[data-theme="light"]`, one step above the dark rule it
   remixes: the pressed pair measures (0,4,0) and (0,5,0) against the dark (0,3,0)
   and (0,4,0), and every other member gains the same step. The one real tie is
   this rule's (0,4,0) base selector against the dark rule's (0,4,0) `:hover`
   selector on a hovered pressed segment, and it is inert only because the (0,5,0)
   selector beside it wins that element outright. Split the two and the base is
   order-decided. Measured 2026-09-07 with the dark pair appended last, winning
   every tie: the light label did not move, hovered or at rest. */
[data-theme="light"] .pagination .btn[aria-current="page"],
[data-theme="light"] .tag-accent,
[data-theme="light"] .tab.active .tab-badge,
[data-theme="light"] .navbar-link.active .navbar-badge,
[data-theme="light"] .nav-item.active .nav-badge,
[data-theme="light"] .step[aria-current="step"] .step-index,
[data-theme="light"] .avatar,
[data-theme="light"] .tree-row[aria-selected="true"],
[data-theme="light"] .dropzone-browse,
[data-theme="light"] .dropzone.is-over .dropzone-lede,
[data-theme="light"] .btn-group > .btn[aria-pressed="true"],
[data-theme="light"] .btn-group > .btn[aria-pressed="true"]:hover {
  color: color-mix(in srgb, var(--accent) 70%, var(--text) 30%);
}

/* ---- Split button ----
   One action plus its neighbours. The primary half is whatever button variant
   the screen already allows; the toggle is the same variant, so the pair reads
   as one object. */
.btn-split { position: relative; display: inline-flex; }
.btn-split > .btn-split-toggle {
  padding-inline: var(--space-2);
  min-width: var(--ctl-sm);
}
/* On an outlined variant the two halves already meet at their own borders. On a
   fill they do not, so the seam is drawn in the label's own colour at 25
   percent: measured 1.58:1 dark and 1.50:1 light against the fill, the same band
   the chart grid reads at. It is not a graphical object under 1.4.11 - the two
   halves are separately focusable and separately named, and the chevron is what
   says the second one opens something. currentColor rather than a token, so it
   tracks the hover and press fills without a rule for each. */
.btn-split > .btn-fill + .btn-fill {
  border-inline-start-color: color-mix(in srgb, currentColor 25%, var(--tint-ground));
}
/* One control: hovering either filled half lifts the pair together. Letting
   `.btn-fill:hover` fire on the half under the pointer alone is what made the
   caret segment read as a separate, brighter button. */
.btn-split:hover > .btn-fill:not(:disabled) {
  background: var(--accent-hover);
  --tint-ground: transparent;
  border-color: var(--accent-hover);
}
/* The outward focus ring wraps one half and reads as a second button on the
   accent fill. Inside and on-accent, the ring stays on the fill the way the
   day cell keeps its own: 8.22:1 dark, 4.60:1 light. */
.btn-split > .btn-fill:focus-visible {
  outline-offset: -2px;
  outline-color: var(--on-accent);
  box-shadow: none;
}
/* the caret turns with the panel, the same half turn `.select-caret` makes */
.btn-split-toggle[aria-expanded="true"] > svg { transform: rotate(180deg); }
.btn-split-toggle > svg { transition: transform var(--dur-near) var(--ease); }

/* The menu is `.popover`, so the surface, the shadow, the glass and the entrance
   all come from where they already live; `.popover` ships no exit, and this is
   the one `.filter-menu` already added. It hangs off the toggle's end, which is
   the edge it belongs to. A split button close to a viewport edge needs the
   placement script `.popover`'s own comment already assumes. */
.btn-split-menu {
  inset-inline-start: auto;
  inset-inline-end: 0;
}

/* ============================================================
   Table extras: expandable rows, a frozen column, density, inline edit
   ============================================================ */

/* All four extend the shipped `.table-wrap` and the one state object the sort,
   selection, paging and filter controllers already share. No new surface. */

/* ---- Expandable rows ----
   The detail is its own `<tr>` under the row it belongs to, so the columns above
   it keep their widths and nothing is absolutely positioned over a scrolling
   table. `aria-expanded` on the toggle is the ONE source of truth: the CSS reads
   it through `:has()` rather than taking a second class, so the costume cannot
   disagree with what assistive tech is told.

   NAMED DEVIATION from transform-and-opacity, the same one `.accordion` carries
   and for the same reason: opening a row is a layout change, everything below it
   moves down, and no transform expresses that. `interpolate-size` is what lets
   block-size leave `auto`; `content-visibility` needs `allow-discrete` or the
   body blanks on frame one of the close, and it is also what keeps a collapsed
   panel out of the tab order and out of the accessibility tree. */
.table-wrap td.cell-expand,
.table-wrap th.cell-expand {
  width: 1%;
  padding-inline: var(--space-3);
}
.row-toggle {
  width: 22px;
  height: 22px;
  border-radius: var(--r-1);
  color: var(--text-4);   /* 4.73:1 dark, 5.55:1 light on --surface-1, over the 3:1 floor */
}
.row-toggle > svg {
  width: 14px;
  height: 14px;
  transition: transform var(--dur-tint) var(--ease);
}
.row-toggle[aria-expanded="true"] > svg { transform: rotate(90deg); }

/* The inline padding lines the panel up with the column text and keeps it off
   the table's own rounded edge; block padding stays 0, or the collapsed state
   cannot reach zero height. */
.table-wrap tr.row-detail > td {
  padding: 0 var(--space-4);
  border-bottom: none;
}
.row-detail-body {
  interpolate-size: allow-keywords;
  block-size: 0;
  overflow: hidden;
  content-visibility: hidden;
  background: var(--well);   /* 1.28:1 dark, 1.23:1 light against the table's surface-1 */
  --tint-ground: transparent;
  /* a detail belonging to its row, not a hole punched across the table: full
     bleed with square corners inside an --r-3 container read as the second */
  border-radius: var(--r-1);
  transition:
    block-size var(--dur-out-far) var(--ease),
    content-visibility var(--dur-out-far) var(--ease);
  transition-behavior: allow-discrete;
}
/* the padding lives on the one child: with border-box a padded box cannot
   collapse below its own padding, so a padded `.row-detail-body` would stay 32px
   tall in the state that is supposed to be gone */
.row-detail-body > * {
  padding: var(--space-4);
  font-size: var(--t-2);
  color: var(--text-2);   /* 9.36:1 dark, 6.79:1 light on --well */
}
.row-expandable:has(.row-toggle[aria-expanded="true"]) + .row-detail > td > .row-detail-body {
  block-size: auto;
  content-visibility: visible;
  transition:
    block-size var(--dur-far) var(--ease-out),
    content-visibility var(--dur-far) var(--ease-out);
  transition-behavior: allow-discrete;
}

/* ---- Frozen first column ----
   `.table-wrap` is already the scrollport, so the column sticks to it with no
   second scroller and no measured offset.

   A STICKY CELL NEEDS AN OPAQUE GROUND OR THE SCROLLED COLUMNS PAINT THROUGH IT,
   and the ground is a different colour on four states. `background: inherit`
   takes whichever one the row is actually wearing, so hover, selection and the
   header all follow with no rule each. That needs the resting row to carry a
   colour at all, which it does not today: the surface belongs to `.table-wrap`.
   The rest fill is therefore declared at (0,1,1), BELOW `.table-wrap tbody
   tr:hover` and `tr.row-selected`, so it loses to both rather than killing them.

   The divider is a box-shadow, not a border: under `border-collapse: collapse`
   the table paints cell borders, not the cell, so a border on a sticky cell
   stays behind while the cell travels. The neutral hairline reads on all four
   grounds - 1.92 / 1.81 / 2.03 / 1.90 dark and 1.77 / 1.69 / 1.68 / 1.70 light
   on --surface-1, the hover fill, the header well and the selection tint. */
.table-sticky-1 tbody tr { background: var(--surface-1); --tint-ground: transparent; }
.table-sticky-1 :is(th, td):first-child {
  position: sticky;
  inset-inline-start: 0;
  z-index: 1;
  background: inherit;
  box-shadow: inset -1px 0 0 0 color-mix(in srgb, var(--text-4) 40%, var(--tint-ground));
}
/* the row's own selection edge is painted by the row and covered by the cell's
   ground, so the frozen cell carries both marks itself */
.table-sticky-1 tbody tr.row-selected :is(th, td):first-child {
  box-shadow:
    inset 2px 0 0 var(--accent),
    inset -1px 0 0 0 color-mix(in srgb, var(--text-4) 40%, var(--tint-ground));
}

/* ---- Density ----
   Three steps, both ends off the spacing scale, around the 10px the table
   already ships. The control that sets them is a `.btn-group` with
   `aria-pressed`, which is the other half of this block. BODY rows only: the
   header keeps the base height at every density, so a dense table's header is
   not the shortest thing on the page and a roomy one's does not outgrow the
   card. */
.table-wrap.table-dense tbody td { padding: var(--space-1) var(--space-3); }
.table-wrap.table-roomy tbody td { padding: var(--space-4); }

/* ---- Inline edit ----
   The cell holds a real field for its whole life rather than swapping one in on
   click: a swap loses focus, loses the caret position, and needs a controller
   before the cell can be entered at all. At rest the field paints as the cell's
   own text and every shipped `.input` state still applies to it.

   IT TAKES NO HEIGHT AND NO FONT OF ITS OWN. `.input`'s 36px ladder step would
   set the height of every row in the column, and its own --t-3 sans would fight
   the table's --t-2 and a `.num-col`'s mono. Sized by the cell's line box
   instead, the field is 2px taller than the cell's text at every density and in
   every column, which the whole row absorbs once. That is also what makes the
   density variants need no rule here.

   The 6px of field padding is pulled straight back out, so the column's text
   sits in exactly the same place whether or not the cell is editable. */
.input-inline {
  display: block;   /* an inline-block field hangs a descender gap under its row */
  height: auto;
  min-height: 0;
  width: calc(100% + 12px);
  min-width: 0;   /* a bare input carries an intrinsic width and would set the column */
  /* Pulled back on BOTH axes. The cell's text may not move, and a field with no
     block padding is a 20px line box, on which --r-1 paints a stadium: the hover
     border then read as a pill laid across the column with the value stranded at
     its far end. 4px each way makes it a 28px box on the same baseline. */
  margin: -4px -6px;
  padding: 4px 6px;
  font: inherit;
  /* `font: inherit` resets the font-variant longhands, and a `.num-col` cell's
     tabular figures are one of them */
  font-variant-numeric: inherit;
  background: none;
  border-color: transparent;
  color: var(--text);   /* 10.12:1 dark, 13.47:1 light on --surface-1 */
}
/* The transparent edge above is (0,1,0) and sits thousands of lines below
   `.input-invalid`, which is (0,1,0) too, so it wins on source order and an
   invalid inline cell painted NO edge at all - measured `rgba(0, 0, 0, 0)` in
   both themes, with or without the unsaved state. The attribute arm of that
   rule is (0,2,0) and always did paint; restating the pair here at (0,2,0)
   makes the class form agree with it, which is what its own comment promises.
   Scoped to this field rather than reordered upstream: the invalid rule dresses
   every control in the system and this is the one whose base rule outranks it. */
.input-inline.input-invalid,
.input-inline[aria-invalid="true"] {
  border-color: var(--danger);   /* 5.18:1 dark, 6.02:1 light on --surface-1 */
}
/* unsaved. The accent hairline rides the field itself, beside the value it
   marks: on the cell's edge it sat about 290px away from a numeric column's
   field and read as a stray line. A BORDER and not a shadow, so it cannot
   composite with the field's own; the hover companion sits at (0,7,1) and beats
   the (0,5,0) neutral hover edge below on specificity, so source order does not
   decide it.

   THE INVALID CARVE-OUT IS LOAD-BEARING, and it is what a shadow did not need:
   unsaved and failed-to-save are two states of one cell, and this selector is
   longer than `.input[aria-invalid="true"]` at (0,2,0) and its `:focus` at
   (0,3,0), so without the `:not()` pair a failed save paints the accent and
   --danger never reaches the field. The old cell shadow composited beside the
   border and could not collide with it. Same pair `.input`'s own hover rule
   carries, for the same reason. A dirty field that is also [readonly] loses to
   that rule's (0,6,0) form, which is a state pair the component cannot be in. */
.table-wrap td.cell-edit.is-dirty .input-inline:not(.input-invalid):not([aria-invalid="true"]),
.table-wrap td.cell-edit.is-dirty .input-inline:hover:not(.input-invalid):not([aria-invalid="true"]) {
  border-color: var(--accent);   /* 6.48:1 dark, 4.60:1 light on --surface-1 */
}
/* `.input:hover:not(:focus):not(.input-invalid):not([aria-invalid="true"])` is
   (0,5,0) and would paint --surface-3, which measures 1.06:1 on the light well.
   Matched here at the same (0,5,0), later in the file. */
.input-inline:hover:not(:focus):not(.input-invalid):not([aria-invalid="true"]) {
  border-color: color-mix(in srgb, var(--text-4) 40%, var(--tint-ground));
}
/* A cell nobody may edit offers no affordance, and carving `[readonly]` out of
   the rule above is not enough: the shipped (0,5,0) rule is still there behind
   it and painted --surface-3 on a read-only field (measured). The state has to
   name its own colour, at (0,6,0). */
.input-inline[readonly]:hover:not(:focus):not(.input-invalid):not([aria-invalid="true"]) {
  border-color: transparent;
}
.input-inline:focus { background: var(--well); --tint-ground: transparent; }
.input-inline[readonly] {
  color: var(--text-2);   /* 7.30:1 dark, 8.36:1 light on --surface-1 */
  cursor: default;
}
/* `text-align` does NOT reach the field from its cell the way the font does: the
   UA stylesheet sets it on the control, so a `.num-col` field measured `start`
   inside a `right` column. The one property the cell cannot hand over.

   A numeric column is as wide as its own heading, so a field filling it drew a
   487px box around the one character `3` (measured on the replicas cell) with
   the value stranded at the far end. Here the field is as wide as its value and
   sits on the column's right edge, where the number already was. */
.num-col .input-inline {
  text-align: right;
  field-sizing: content;
  width: auto;
  min-width: 3ch;
  margin-inline-start: auto;
}
/* the field shares its cell with a spinner or a retry: it gives up the 100%
   width and takes what the row leaves, keeping its own pulled-back padding */
.cell-edit-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.cell-edit-row > .input-inline { width: auto; flex: 1 1 auto; }
.cell-edit-row > .spinner { flex-shrink: 0; }
/* A cell's error sits under its field. --danger measures 5.18:1 dark and 6.02:1
   light on --surface-1. Keep it to three or four words: a table column sizes to
   its widest cell, so a sentence here widens the whole column. In a right-aligned
   column it ends where the value ends, or the pair reads as two unrelated things. */
.cell-edit .field-error { margin-top: 2px; }
.num-col > .field-error { justify-content: flex-end; }

/* ============================================================
   Coarse pointers
   ============================================================ */
@media (pointer: coarse) {
  /* the trigger grows with its chips, so `.input`'s 44px floor cannot reach it */
  .select-multi > .select-trigger { min-height: var(--ctl-lg); }
  /* `.filter-chip`'s coarse rule resets padding-inline as a shorthand and takes
     the 4px end that makes room for the chip's × with it; restated per side */
  .select-chip {
    padding-inline-start: 10px;
    padding-inline-end: 4px;
  }
  /* A clear that grows to 44px inside a 44px field is the whole field. The hit
     area grows instead and the box holds still, so the query text keeps its
     slot: a pseudo takes pointer events like any other box. */
  .select-clear::before {
    content: '';
    position: absolute;
    inset: calc((var(--ctl-lg) - 22px) / -2);
  }
  /* an editable cell IS the tap target here, so the table gets taller rather
     than the field getting smaller. min-height, not height: the field still
     takes its size from the cell's own line box above the floor. */
  .input-inline { min-height: var(--ctl-lg); }
  /* a dense table on a phone is not a density choice, it is a miss: the variant
     collapses onto the base row, the way .btn-sm collapses onto the floor. BODY
     rows only, like the density rules themselves: the header takes the base
     height at every density and this block must not be the one place it does
     not, however equal the two values happen to be today. */
  .table-wrap.table-dense tbody td { padding: 10px var(--space-4); }
  /* the coarse floor for every bare icon button, and it has to be THIS block:
     each of these has a (0,1,0) box rule further down the file that an earlier
     coarse declaration would lose to on source order */
  .close,
  .copy-chip-btn,
  .row-toggle { width: var(--ctl-lg); height: var(--ctl-lg); }
  .input-step { width: var(--ctl-lg); }
  .btn-split > .btn-split-toggle { min-width: var(--ctl-lg); }
}

/* ============================================================
   Reduced motion: the fades stay, the moves go
   ============================================================ */
@media (prefers-reduced-motion: reduce) {
  /* a size change has no fade to keep, so it snaps, the way the accordion does.
     Restated at the entrance rule's own specificity, which is (0,5,0) with the
     :has() argument counted, or the block-size transition comes back. */
  .row-detail-body,
  .row-expandable:has(.row-toggle[aria-expanded="true"]) + .row-detail > td > .row-detail-body {
    transition: none;
  }
  /* the chevron still turns, it just arrives there */
  .row-toggle > svg { transition: none; }
  .btn-split-toggle > svg { transition: none; }
  .dropzone-file { animation-name: panel-in-fade; }
}

/* ---- Typed placeholder ----
   The third sanctioned ambient effect, and the only one that is a component. An
   overlay on the field types one real example, holds it, deletes it, and moves to
   the next. `data-typed` on the field carries the examples; `ui.js` builds the
   wrapper and the overlay and owns the loop.

   IT RENDERS REAL VALUES, so it is content and not a decorative hint.
   `--text-placeholder` is what the native placeholder takes; this sits one step
   up at `--text-3`, measured on `--well`: 7.05:1 dark, 4.82:1 light, against the
   placeholder's 4.99 / 4.52. A field beside an already-labelled control is
   exactly where a dimmer value stops being readable.

   Geometry is copied off `.input` rather than inherited, because the overlay is a
   sibling and not a child: same 12px inline padding, same 14px/1.4 Onest, so a
   typed glyph lands on the caret's own baseline. */
.typed-wrap { position: relative; display: block; }
.typed-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  padding: 0 12px;
  font: 400 var(--t-3)/1.4 var(--font-sans);
  color: var(--text-3);
  pointer-events: none;
  user-select: none;
  /* the examples are machine-ish strings (an address, a handle): they never wrap
     and they never widen the field */
  white-space: pre;
  overflow: hidden;
}
/* an icon wrap starts the field's text at the icon's outer edge, and the
   overlay is the input's sibling inside its wrap: it needs the same start,
   or the first typed glyph lands under the icon */
.input-wrap.has-icon .typed-overlay { padding-left: 32px; }
/* each character fades on its own clock, so the line arrives as typing rather
   than as a string appearing. The per-character fade is the one part of the
   effect that rides a duration token; the cadence is a typing rate, not a
   travel, so it takes nothing from the ladder (80ms in, 50ms out, in `ui.js`). */
.typed-char { opacity: 0; transition: opacity var(--dur-near) var(--ease); }
/* Reduced motion: `ui.js` never builds the overlay at all, so there is nothing to
   restate here. This rule exists for a consumer that hand-builds the markup. */
@media (prefers-reduced-motion: reduce) {
  .typed-overlay { display: none; }
}

/* ---- Draw-on underline ----
   A swash that draws itself under a highlighted word once on load. The host is
   the highlighted text, made inline-block relative; the consumer pastes this
   svg inside it with the path normalized (pathLength="1"), so the css dash
   sweep draws any shape with no js and no measured length. The stroke is the
   one shape a transform cannot draw, which is why the sweep rides
   stroke-dashoffset (motion.md's exception table). Reduced motion keeps the
   full underline and drops the sweep. */
.draw-underline {
  position: absolute;
  left: 0;
  bottom: -0.16em;
  width: 100%;
  height: 0.38em;
  overflow: visible;
  pointer-events: none;
}
.draw-underline path {
  fill: none;
  stroke: var(--accent-2);
  stroke-width: 3;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  animation: draw-underline var(--dur-far) var(--ease-out) forwards;
}
@keyframes draw-underline {
  to { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .draw-underline path {
    animation: none;
    stroke-dashoffset: 0;
  }
}

/* ---- Glass ----
   Real background blur, on the surfaces something actually passes behind.
   Which six they are is in references/glass.md; the navbar is not one of them,
   because at the top nothing passes behind it and a full-width blurred band is
   the slab the strength round rejected. The scrim itself stays a flat tint.

   Two materials, one filter. Bars tint with --bg: a bar is the page held still,
   and on a dark page a surface-tone bar reads as an opaque slab whatever the
   blur (measured, round 3). Panels keep the raised surface tone at the clearest
   alpha the dim ramp allows. The worst-case ratios live in tokens.css beside
   the tokens they were measured on.

   Opaque is the default and glass is the enhancement. An engine without
   backdrop-filter keeps the measured opaque surface rather than bare
   translucency with nothing blurred behind it, which is the failure mode this
   @supports gate exists for.

   This block sits last in the file on purpose: every rule here restates a
   component's own background at the same specificity, so source order is what
   decides it. */
/* ---- Disabled beats every state costume ----
   AN ALPHA DIMMED WHATEVER WON THE CASCADE BENEATH IT. A colour has to WIN that
   cascade, and each component's own disabled rule sits at (0,2,0), which four
   state costumes outrank or tie-and-follow. Measured as painted, each rendering
   the fully enabled costume with `disabled` set:

     .btn-group > .btn[aria-pressed="true"]      (0,3,0)  accent-text on accent-quiet
     .pagination .btn[aria-current="page"]       (0,3,0)  accent fill, text and edge
     [data-theme="light"] .tree-row[…selected]   (0,3,0)  the accent label, light only
     .daterange-day.is-in-range / .is-start      (0,2,0)  later in the file, so it wins
     .ctxmenu > .menu-item.active                         keeps the accent-quiet fill

   `!important` rather than a specificity ladder, because the next state costume
   would climb the ladder again and this has to hold whatever is written under it.
   It is the same tool `.btn-loading` uses one screen up for the same reason, and
   that rule is excluded here: a button disabled WHILE loading must keep its label
   hidden under the spinner, which is the one costume that outranks being off.
   The fill list is explicit rather than blanket, or a bare disabled `.btn` would
   grow a fill it does not have at rest.

   A STATIC GUARD CANNOT SEE THIS. The check that keeps it honest mounts each
   state and reads the painted colour; that gate is only as good as the
   specimens the pages carry.

   THIS IS THE ONLY DECLARATION of the disabled label colour for the four
   components in the list. Each of them used to restate it as well, six dead
   declarations that this rule already beat at every site, and a reader met the
   dead one first. `.btn-loading` is the one branch this rule skips, and there
   `.btn-loading { color: transparent !important }` wins instead, so the six
   were unreachable on both branches. */
:is(.btn, .daterange-day, .tree-row, .menu-item):is(:disabled, [aria-disabled="true"]):not(.btn-loading) {
  color: var(--text-disabled) !important;
}
:is(.btn, .daterange-day, .menu-item):is(:disabled, [aria-disabled="true"]):is([aria-pressed="true"], [aria-current="page"], .is-in-range, .is-start, .is-end, .active) {
  background: var(--surface-2) !important;
  --tint-ground: transparent;
  border-color: var(--line) !important;
}

@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .mobile-topbar,
  .section-sticky.is-stuck,
  .popover,
  .select-menu,
  .toast,
  .modal {
    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
    backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
    /* the tokens with no headroom left on a translucent panel. --danger is
       3.31:1 unlifted, and a translucent accent fill laid over a translucent
       surface composites twice: 4.40:1 dark, 4.04 light, against 5.95 and 4.89
       for the same mix laid down opaque on a panel. Numbers and method in
       tokens.css. */
    --text-3: var(--glass-dim);
    --text-4: var(--glass-dim);
    --text-placeholder: var(--glass-dim);
    /* NOT --glass-dim: a disabled label collapsed onto the dim step reads as an
       enabled one, and the panel is where the ramp has least room to say
       otherwise. Its own lift is the lowest value still clearing 3:1 there. */
    --text-disabled: var(--glass-disabled);
    --danger: var(--glass-danger);
    --danger-text: var(--glass-danger-text);
    /* mixed HERE, on the surface, because a custom property resolves its var()
       where it is declared: a mix on :root would read the root's transparent
       --glass-ground. On a bar both land on --bg, on a panel on --surface-1. */
    --accent-quiet: color-mix(in oklab, var(--accent) var(--glass-quiet-share), var(--glass-ground));
    --accent-line: color-mix(in srgb, var(--accent) var(--glass-line-share), var(--glass-ground));
    /* the dimmed icon is a graphic against the same moving surface: on the dark
       panel 0.85 leaves a text-3 icon at 2.99:1, under the 3:1 floor, so the
       lift is 0.90: 3.15 dark and 3.89 light on the panel worst cases */
    --icon-dim: 0.90;
  }
  /* the bar is the page held still: page colour, hairline, and whatever passes
     under it, ghosting through the blur. It re-floors the same tokens as a
     panel, because the block above names it: a bar eyebrow or placeholder reads
     --glass-dim, --glass-danger and --glass-disabled there too (the sheet's
     stuck-header specimen paints a .label on it). */
  /* --tint-ground is each surface's own tone, so a hover, press or tag tint
     inside it is an opaque mix the backdrop cannot move; any opaque ground
     nested inside resets it to transparent (the --tint-ground token's note). */
  .mobile-topbar,
  .section-sticky.is-stuck { background: var(--glass-bar-tint); --tint-ground: var(--bg); --glass-ground: var(--bg); }
  .popover,
  .select-menu,
  .toast,
  .modal { background: var(--glass-tint); --tint-ground: var(--surface-1); --glass-ground: var(--surface-1); }
  /* A full-bleed bar has one edge that meets the page, so it keeps the hairline
     it already had and only changes its colour. A floating panel gets all four:
     over a photo or a card its own tone is no longer the thing that separates
     it. .select-menu is not here on purpose, its border is the open outline. */
  .mobile-topbar { border-bottom-color: var(--glass-edge); }
  /* a stuck header has no border of its own to recolour, so the edge is added */
  .section-sticky.is-stuck { border-bottom: 1px solid var(--glass-edge); }
  .popover,
  .toast,
  .modal { border: 1px solid var(--glass-edge); }
}

/* ---- Reduced transparency: the material goes back to its opaque surface ----
   --glass-dim resolves on :root, where --text-3 is still the token's own value,
   so the remap above becomes an identity rather than a cycle. */
@media (prefers-reduced-transparency: reduce) {
  .mobile-topbar,
  .popover,
  .select-menu,
  .toast,
  .modal,
  .section-sticky.is-stuck {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    /* `unset` on a custom property is `inherit`, so each surface takes the root
       token back without this block restating a single colour. Resetting
       --glass-dim on :root instead cancels the --text-3 remap and leaves
       --text-4 pointing at it, which shipped a three-step ramp here. */
    --text-3: unset;
    --text-4: unset;
    --text-placeholder: unset;
    --text-disabled: unset;
    --danger: unset;
    --danger-text: unset;
    --accent-quiet: unset;
    --accent-line: unset;
    --icon-dim: unset;
    --tint-ground: unset;
    --glass-ground: unset;
  }
  /* the surface is the only thing that differs across the six */
  .mobile-topbar,
  .popover,
  .select-menu,
  .toast,
  .modal { background: var(--surface-1); }
  /* the sticky header's own pre-glass surface is the page, so that is what
     reduce restores, with the plain hairline in place of the glass edge */
  .section-sticky.is-stuck {
    background: var(--bg);   /* same tone the @supports-less fallback paints */
    border-bottom: 1px solid var(--line-soft);
  }
  .mobile-topbar { border-bottom-color: var(--line-soft); }
  .popover,
  .toast,
  .modal { border: none; }
}
