/* components.css — the design language as reusable parts.
 *
 * `nominate.css` carries the palette and `skin.css` reskins LOOM's own widgets.
 * This file is the third layer: the components an Apex app builds itself, in
 * the same language, so the second app does not re-derive them from a
 * screenshot of the first.
 *
 * The file has two halves, and they arrived from opposite directions:
 *
 *   PART ONE  — extracted from cbprospect after building it once. The parts an
 *               app invents for itself: stat, meter, chip, record, ledger.
 *   PART TWO  — ported IN from the design system's own widget zoo
 *               (`cbnominate/site/src/styles/tokens.css` + `lab/.../
 *               lab-components.css`), which until now no app could reach: it
 *               lived inside an Astro site and a Playwright screenshot suite.
 *               Buttons, form fields, tags, the console, the provenance trace,
 *               the overlap stack.
 *
 * Everything here earned its place by being needed; nothing is speculative.
 *
 * ── TWO WAYS TO USE IT ─────────────────────────────────────────────────────
 * 1. Write `ax-*` classes in your markup. This is the normal case for a page
 *    you author, and it is the whole point of the file.
 *
 * 2. If you are skinning FOREIGN markup — a page ported from cbloom whose class
 *    names you do not control and should not edit — you cannot add classes.
 *    Use the `--ax-*` custom properties below in your own selectors instead.
 *    The decisions then stay shared even though the selectors cannot be:
 *
 *        .their-card {
 *          border: var(--ax-line);
 *          border-radius: var(--ax-r);
 *          box-shadow: var(--ax-lift);
 *        }
 *
 *    That is why the values are properties and not just rules. cbprospect does
 *    exactly this, because its page came across from cbloom intact.
 *
 * ── THE RULES, STATED ONCE ─────────────────────────────────────────────────
 *   Lines are 2px. A hairline is what a browser draws by default; a stated
 *   border is a decision and should look like one.
 *
 *   Depth is a hard shadow, zero blur, offset down-right. A soft shadow reads
 *   as a different product.
 *
 *   Radii are tight (3/5/7/9px). A 10px pill around an 11px label is a bubble.
 *
 *   Labels are mono, uppercase, letterspaced, accent-coloured. Numbers are
 *   tabular and large. The label recedes and names the thing; the number
 *   carries the weight. That pairing is the system's voice.
 */

:root {
  /* Consume the palette through the --n-* aliases nominate.css defines, never
     a literal. When the design system moves, these move with it. */
  --ax-line: 2px solid var(--line, var(--n-line));
  --ax-line-accent: 2px solid var(--accent, var(--n-accent));
  --ax-r-tight: 3px;
  --ax-r: 5px;
  --ax-r-loose: 7px;
  --ax-lift: var(--n-hard-card);
  --ax-lift-sm: var(--n-hard);
  /* The inverse of a lift, for the one case that needs it: a form field. An
     input is the same material as a card but must not look raised, or it reads
     as a button and gets clicked instead of typed in. */
  --ax-sink: inset 2px 2px 0 rgba(7, 5, 14, 0.28);
  --ax-mono: var(--font-mono, var(--n-font-mono, ui-monospace, monospace));

  /* The label idiom, as parts — so a one-off selector can borrow it without
     copying four declarations and getting one of them slightly wrong. */
  --ax-label-size: var(--ax-fs-micro);
  --ax-label-track: 0.12em;

  /* ── THE TYPE SCALE (operator, 2026-10-09: "the same sizes and rules
     across all Apex apps — except the colours"). Eight steps, and every
     font-size in the kit is one of them; `type-scale.test.ts` fails a kit
     file that writes a literal and NOTICEs an app that does. Before this the
     kit alone declared 49 different sizes (240 declarations) and one Convo
     Ops screen rendered 15 of them, 8.8px to 32px; the zoo rendered 38.

       micro    11px   mono uppercase labels: eyebrows, chips, tags, kbd,
                       the footer pills. THE FLOOR — nothing is smaller.
       xs       12px   captions, timestamps, meta lines
       sm       14px   secondary text, hints, buttons, table cells, chips' kin
       md       16px   body (the root)
       lg       18px   card and row titles, h3
       xl       22px   section headings, h2
       2xl      28px   big numbers, h1 inside a page
       display  32→48  the page's hero h1, fluid with the viewport

     ⚠️ The floor moved: --ax-label-size was 0.62rem (9.9px) and parts of the
     chrome sat at 0.52–0.58rem (8.3–9.3px). Everything mono and small now
     reads at 11px, which makes chips and pills slightly wider — measured
     against the zoo and the shared screens at 390 before shipping.

     An app may not add a step. If a size is missing, the scale is wrong and
     the fix is here, for every app. `em` stays legal for text that should
     track its parent (inline code, a kbd inside a button). */
  --ax-fs-micro: 0.6875rem;
  --ax-fs-xs: 0.75rem;
  --ax-fs-sm: 0.875rem;
  --ax-fs-md: 1rem;
  --ax-fs-lg: 1.125rem;
  --ax-fs-xl: 1.375rem;
  --ax-fs-2xl: 1.75rem;
  --ax-fs-display: clamp(2rem, 1.4rem + 2.5vw, 3rem);
  /* Line height pairs with size: tight for anything at lg and above, body
     for running text, snug for the small mono labels. */
  --ax-lh-tight: 1.2;
  --ax-lh-snug: 1.35;
  --ax-lh-body: 1.5;
}

/* ══ Type — the scale ═══════════════════════════════════════════════════════
   The eight --ax-fs-* steps are declared in :root above; this section is the
   rules that ride with them. The zoo's Type group shows every step. */

/* Bare headings take the scale too, so an unclassed <h3> is 18px in every
   app rather than the browser's 1.17em of whatever it sits in (18.72px on a
   page, 15.2px in a card — both measured in Convo Ops). Element selectors,
   so any class an app or the kit puts on a heading still wins. */
h1 {
  font-size: var(--ax-fs-2xl);
  line-height: var(--ax-lh-tight);
}
h2 {
  font-size: var(--ax-fs-xl);
  line-height: var(--ax-lh-tight);
}
h3 {
  font-size: var(--ax-fs-lg);
  line-height: var(--ax-lh-tight);
}
h4,
h5,
h6 {
  font-size: var(--ax-fs-md);
}
/* Form controls inherit the type instead of the browser's 13.33px (an
   icon button measured exactly that in the zoo). :where() so it has no
   specificity: any authored rule, Loom's included, still wins. */
:where(button, input, select, textarea) {
  font-size: inherit;
}
/* <small> is a step of the scale, not the browser's `smaller`. The UA rule
   renders it at 0.83em of its parent — 13.33px under body, 11.67px in a 14px
   cell, 9.2px inside a micro label — none of them a step, and the last one
   under the floor. Measured 2026-10-12: 235 <small> elements across the
   family (the kit's own screens included) rode the UA rule because no sheet
   sized the element. xs is the caption step, which is what <small> means.
   :where(), so a classed rule (`.shell-so-who small`, micro) still wins. */
:where(small) {
  font-size: var(--ax-fs-xs);
}

/* ══ Eyebrow — the section label ═══════════════════════════════════════════
   Every heading in this system is preceded by one. It is the smallest, quietest
   thing on screen and the only accent-coloured text, which is what makes it
   read as structure rather than emphasis. */
.ax-eyebrow {
  font-family: var(--ax-mono);
  font-size: var(--ax-label-size);
  letter-spacing: var(--ax-label-track);
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 0.25rem;
}

/* ══ Card — the raised surface ═════════════════════════════════════════════ */
/* ⚠️ PADDING THROUGH THE DENSITY TOKENS, not numbers. `data-density` on <html>
   is set by shell-controls.js and defines --shell-box-*; a component with
   hardcoded padding simply does not answer the picker. That is what "density
   not working" means in practice — the attribute flips, the buttons light up,
   and nothing on screen moves, which reads as a broken control rather than an
   unwired one. The fallback keeps the designed value for any page without the
   strip. */
.ax-card {
  background: var(--surface);
  border: var(--ax-line);
  border-radius: var(--ax-r);
  box-shadow: var(--ax-lift);
  padding: var(--shell-box-y, 0.9rem) var(--shell-box-x, 1rem);
}
/* A DOOR — a card that is a link (or a button), with a stripe on its left edge
   in the colour of what it opens (`--ax-door`, defaulting to the accent). Four
   apps had written it (home-door, cn-rec.is-door, cbpublic twice) before the
   2026-08-28 sweep; every home grid is a deck of these. `<a class="ax-card
   ax-door" style="--ax-door: #15e3cc">` — the whole card is the target; hover
   and focus draw a ring in the same colour, never colour alone (the ring is a
   shape). A `<button>` door gets the same box. */
.ax-door {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  color: inherit;
  text-decoration: none;
  cursor: pointer;
  border-left: 4px solid var(--ax-door, var(--accent));
}
.ax-door:hover,
.ax-door:focus-visible {
  box-shadow: var(--ax-lift), 0 0 0 2px var(--ax-door, var(--accent));
  outline: none;
}
/* A DOOR ROW — a table row that goes somewhere (cbapex#97; cbrural's
   `.ax-ledger tr.is-door` at seven sites and cbprospect's chevron column were
   the two). The same object as the card door in a table's shape, so the same
   mark: the 4px `--ax-door` stripe, on the FIRST CELL because a row has no
   box of its own to border, visible without hover (an iPad has none). Hover
   and focus tint the cells; focus also draws an inset ring, a shape.
   ⚠️ `.ax-door` alone would break the table: it sets `display: block;
   width: 100%`, which pulls a <tr> out of its row group. `tr.ax-door` (0,1,1)
   puts the row back and out-specifies the card rules it must not inherit.
   The ROW is the target: give it `tabindex="0" role="link"` and an Enter
   handler, or keyboard users cannot open it at all. */
tr.ax-door {
  display: table-row;
  width: auto;
  text-align: inherit;
  /* The card door's border-left is inert on a row under the default
     separate-borders model, and a SECOND stripe under border-collapse:
     collapse (cbrural's note, 2026-09-24). The mark is the first cell's. */
  border-left: 0;
}
tr.ax-door > td:first-child {
  box-shadow: inset 4px 0 0 var(--ax-door, var(--accent));
}
tr.ax-door:hover,
tr.ax-door:focus-visible {
  box-shadow: none;
}
tr.ax-door:hover > td,
tr.ax-door:focus-visible > td {
  background: color-mix(in srgb, var(--ax-door, var(--accent)) 9%, transparent);
}
tr.ax-door:focus-visible {
  outline: 2px solid var(--ax-door, var(--accent));
  outline-offset: -2px;
}

/* ══ Deck — a row of cards that reflows ════════════════════════════════════
   The card had no container. Every app wrote its own, and by the time there
   were seven of them there were SIXTEEN copies of
   `repeat(auto-fit|auto-fill, minmax(N, 1fr))` with N anywhere from 13rem to
   22rem — the operator's standing rule ("a component used several times
   belongs in the zoo") with the receipts attached.

   Tune the track rather than redeclaring the grid:

       <div class="ax-deck" style="--ax-deck-min: 15rem"> … </div>

   ⚠️ `min(100%, …)` IS THE POINT, and it is the part every hand-rolled copy
   got wrong except cbnight's. A bare `minmax(19rem, 1fr)` forces a 19rem track
   even when the container is narrower than 19rem, so the deck overflows its
   parent and the page grows a horizontal scrollbar. Clamping the minimum to
   the container's own width makes it degrade to one column instead.

   ⚠️ auto-FILL, not auto-fit, and the difference only shows when the deck is
   nearly empty: auto-fit COLLAPSES the empty tracks, so a single card stretches
   to the full width of the row and reads as a banner. auto-fill keeps them, so
   one card stays card-sized. Where stretching is what you want — a fixed set of
   four that should always fill the row — say so with `.ax-deck.is-fluid`. */
.ax-deck {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--ax-deck-min, 19rem)), 1fr));
  gap: var(--ax-deck-gap, 1rem);
  /* Cards in one row share a height; ragged bottoms read as broken. */
  align-items: stretch;
}
.ax-deck.is-fluid {
  grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ax-deck-min, 19rem)), 1fr));
}
/* COMPACT — stat tiles two-up on a phone (promoted from cbassay's `as-stats`,
   cbapex#168; cbvmod hand-rolled the same). The default 19rem track stacks
   tiles one per row at 390, so a view opened with a full screen of numbers and
   the work sat a swipe below. 9rem is two-up and the number still fits; the
   tiles FILL the row, because seven tracks for four tiles at 1440 read as a
   dashboard that stopped. */
.ax-deck.is-compact {
  --ax-deck-min: 9rem;
  --ax-deck-gap: 0.6rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ax-deck-min)), 1fr));
}
@media (max-width: 40rem) {
  .ax-deck.is-compact .ax-stat-n {
    font-size: var(--ax-fs-xl);
  }
}
@media (min-width: 40rem) {
  .ax-deck.is-compact {
    --ax-deck-min: 11rem;
    --ax-deck-gap: 0.8rem;
  }
}

/* An inset panel. Sits INSIDE something, so it recedes rather than lifting —
   depth in this system is a statement about stacking, not decoration. */
.ax-panel {
  background: var(--space, var(--surface3));
  border: var(--ax-line);
  border-radius: var(--ax-r);
  padding: var(--shell-box-y, 0.8rem) var(--shell-box-x, 1rem);
}

/* ══ Flow — the air between stacked components ═════════════════════════════
   Every component here carries NO outer margin, on purpose: the container
   spaces them (see `.ax-sect` below — a sibling margin on top of a grid gap
   measured 2.2rem where the deck asked for 1). But the kit only ever shipped
   ONE such container, `.ax-deck`, and a page's commonest container is a plain
   `<div id="content">` in block flow — where "the container spaces them"
   meant NOTHING spaced them. Measured 2026-09-19 on Save VI at 402px: cards
   flush against cards, a lede flush against its empty state, a table flush
   against the form under it, all 0px (operator: "no spacing between
   components").

   So block flow gets a rhythm, and ONLY block flow:
   ⚠️ the rule is zero-specificity (`:where`) so any app rule wins, and it
   stands down inside every flex and grid container the kit ships (`> *` reset
   below), where `gap` is the spacer and a margin would double it. In BLOCK flow
   adjacent margins COLLAPSE, so an app that already set one (1.25rem under a
   tally) keeps its own rather than gaining ours on top.
   Inside a card the step is smaller — the card's padding is already air. */
:where(.shell-main) {
  --ax-flow: 1.25rem;
}
:where(.ax-card, .ax-sect, .ax-panel, .ax-dialog-body) {
  --ax-flow: 0.75rem;
}
:where(.shell-main)
  :where(
    .ax-card, .ax-sect, .ax-panel, .ax-deck, .ax-list, .ax-ledger, .ax-ledger-box, .ax-files, .ax-kv, .ax-steps,
    .ax-empty, .ax-error, .ax-warn, .ax-notice, .ax-blank, .ax-actions, .ax-filters, .ax-tabs, form,
    h2, h3, p, .hero
  )
  + :where(
    .ax-card, .ax-sect, .ax-panel, .ax-deck, .ax-list, .ax-ledger, .ax-ledger-box, .ax-files, .ax-kv, .ax-steps,
    .ax-empty, .ax-error, .ax-warn, .ax-notice, .ax-blank, .ax-actions, .ax-filters, .ax-tabs, form
  ),
:where(.shell-main)
  :where(
    .ax-card, .ax-sect, .ax-panel, .ax-deck, .ax-list, .ax-ledger, .ax-ledger-box, .ax-files, .ax-kv, .ax-steps,
    .ax-empty, .ax-error, .ax-warn, .ax-notice, .ax-blank, .ax-actions, .ax-filters, .ax-tabs, form
  )
  + :where(h2, h3, p:not(.ax-hint)) {
  margin-top: var(--ax-flow);
}
/* The stand-down: children of the kit's own flex and grid containers are
   spaced by `gap`. Same zero specificity, later in the file, so it wins the
   tie. An APP grid is the app's to state, and ⚠️ ONLY A MARGIN RESET ON THE
   CHILD WORKS: `.my-grid > * { margin-top: 0 }`. Setting `--ax-flow: 0` on the
   grid does NOT reach, because `.ax-card` / `.ax-sect` / `.ax-panel` each
   redefine `--ax-flow` for themselves (the 0.75rem step above), so a card
   inside the grid reads its OWN value, not the grid's. Measured in cbprospect's
   card grid (cbapex#154): cards 2..n pushed down 12px, the grid's variable
   ignored. */
:where(.ax-deck, .ax-actions, .ax-filters, .ax-split, .ax-tabs, .ax-sect-head, .hero-meta, .ax-dialog-actions) > * {
  margin-top: 0;
}

/* ══ Stat — a number and what it counts ════════════════════════════════════
   The most reused shape in the family: a count, its label, and one line saying
   what it means. `--ax-stat-mark` colours the left edge when the number carries
   a state (good / warning / bad). */
.ax-stat {
  background: var(--surface);
  border: var(--ax-line);
  border-left: 2px solid var(--ax-stat-mark, var(--line));
  border-radius: var(--ax-r);
  box-shadow: var(--ax-lift);
  padding: var(--shell-box-y, 0.85rem) var(--shell-box-x, 1rem);
}
/* FLAT — the de-iframe modifier (operator, 2026-08-27): a worklist page
   stacking several .ax-stat sections reads as boxes-inside-a-box ("eliminate
   the extra container… light and uncluttered"). .ax-flat keeps the count, the
   label and the state MARK (now a hairline top rule instead of a left edge, so
   sections still separate and is-ok/is-bad still speak) and drops the card:
   no border, no surface, no lift, and only vertical padding — the content
   shares the page's gutter instead of insetting again. Opt-in per section;
   a lone stat on a dashboard keeps the card. */
.ax-stat.ax-flat {
  background: none;
  border: 0;
  border-top: 2px solid var(--ax-stat-mark, var(--line));
  border-radius: 0;
  box-shadow: none;
  padding: var(--shell-box-y, 0.85rem) 0;
}
.ax-stat.is-ok {
  --ax-stat-mark: var(--green);
}
.ax-stat.is-warn {
  --ax-stat-mark: var(--amber);
}
.ax-stat.is-bad {
  --ax-stat-mark: var(--red);
}

.ax-stat-n {
  font-size: var(--ax-fs-2xl);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.ax-stat-l {
  font-family: var(--ax-mono);
  font-size: var(--ax-label-size);
  letter-spacing: var(--ax-label-track);
  text-transform: uppercase;
  color: var(--accent);
  margin-top: 0.35rem;
}
.ax-stat-s {
  font-size: var(--ax-fs-xs);
  color: var(--subdued);
  margin-top: 0.4rem;
  line-height: 1.45;
}

/* ══ Section — a titled run of rows ═══════════════════════════════════════
   The worklist page's unit. `.ax-stat` leads with the NUMBER at 1.9rem, which
   is right for a dashboard grid where every tile is one fact. Stacked six deep
   on a queue page it puts six lone numerals down the screen, and the first —
   a subtotal — is the loudest thing on it (VI Pulse HR, the operator's
   2026-08-27 screenshot: a "4" above "OVERDUE SIGNATURES"). A section leads
   with its TITLE and carries the count as a chip, so the one big number on the
   page is the hero's total and everything under it is subordinate.
   The top rule is the state signal — the same token `.ax-stat` paints on its
   left edge, so `is-ok` / `is-warn` / `is-bad` mean the same thing here. No
   frame: the rows are the surface (the de-iframe rule). No `display` is
   authored on the section itself, so `[hidden]` needs no pairing there; the
   head is (0,1,0), under the namespace guard. */
.ax-sect {
  padding-top: var(--shell-row-y, 0.5rem);
  border-top: 2px solid var(--ax-stat-mark, var(--line));
}
/* No margin of its own, like `.ax-stat`: the CONTAINER spaces sections — an
   `.ax-deck` gap, a grid, a flow — and a sibling margin on top of a grid gap
   measured 2.2rem between sections where the deck asked for 1 (VI Pulse HR,
   first render). A call site that wants more air sets `--ax-deck-gap`. */
.ax-sect.is-ok {
  --ax-stat-mark: var(--green);
}
.ax-sect.is-warn {
  --ax-stat-mark: var(--amber);
}
.ax-sect.is-bad {
  --ax-stat-mark: var(--red);
}
.ax-sect-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.35rem 0.75rem;
  flex-wrap: wrap;
  margin-bottom: 0.5rem;
}
/* The title is an <h2> — a section is a heading level, and a screen reader's
   heading list is how a long worklist is navigated on a phone. */
.ax-sect-t {
  margin: 0;
  min-width: 0;
  font-size: var(--ax-fs-md);
  font-weight: 800;
  letter-spacing: -0.01em;
  line-height: 1.3;
}
/* One quiet line under the title row: the criterion ("unsigned more than 48h
   after sending") or the empty state. Full-width, so it never shares the line
   with the chip. */
.ax-sect-s {
  flex-basis: 100%;
  margin: 0;
  font-size: var(--ax-fs-xs);
  line-height: 1.45;
  color: var(--subdued);
}
/* The trailing group of a section head — a chip AND a button, kept together on
   the right; without it flex's space-between spreads three children evenly and
   the chip lands mid-line. */
.ax-sect-e {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
/* A control beside a heading is compact, the way one in `.hero-meta` is: an
   "Add agreement" at form size (46px) beside a 1rem title makes the title the
   small thing. Same numbers as `.hero-meta .ax-btn` in shell.css. */
.ax-sect-head .ax-btn {
  min-height: 1.75rem;
  padding: 0.25rem 0.7rem;
  font-size: var(--ax-fs-xs);
}

/* ══ Split — a list beside the thing it opens ═════════════════════════════
   Master/detail. ONE column on a phone: the list, or — once `.is-detail` is
   set — the detail with a back link above it. Two columns from 64rem, and the
   back link goes (the list IS the way back). The page's one hero sits ABOVE
   this and belongs to whichever side owns the page: the roster's fact when
   nothing is open, the person when someone is.
   Replaces the Loom drawer in VI Pulse HR (2026-08-28): at 390 that collapsed
   to a 24px rail and the pane said "select an employee on the left" — a
   desktop sentence on a phone. The family precedent is cbsvi's voter lookup:
   a row is a LINK to `#/<thing>/<id>`, the detail opens with a back link, and
   the browser's own back button returns to the list. */
.ax-split {
  display: grid;
  gap: 1.5rem;
  align-items: start;
}
.ax-split-back {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  min-height: 2rem;
  margin-bottom: 0.5rem;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-xs);
  letter-spacing: 0.04em;
  color: var(--subdued);
  text-decoration: none;
}
.ax-split-back:hover {
  color: var(--accent);
}
@media (max-width: 63.99rem) {
  .ax-split.is-detail > .ax-split-list {
    display: none;
  }
  .ax-split:not(.is-detail) > .ax-split-detail {
    display: none;
  }
}
@media (min-width: 64rem) {
  .ax-split {
    grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr);
    gap: 2.5rem;
  }
  .ax-split-back {
    display: none;
  }
}

/* ══ Drawer — an edge panel whose door is a picture of what is behind it ═══
   An auxiliary panel docked to the RIGHT edge, under the sticky stack, over
   the page: closed, only its TAB shows — a thumbnail of the panel's own
   content (the state's districts, at icon size) with a one-word label under
   it — and open, the tab rides the panel's left edge and is also the way
   back. Non-modal on purpose: the page stays live behind it, because what
   the panel does is NARROW the page (tap a district, the list filters).
   Promoted from cbprospect's `pr-drawer` and cbnight's `nt-drawer` (18 and 24
   rules, one shape, cbapex#97).

   ⚠️ THE WIDTH IS DERIVED FROM THE VIEWPORT MINUS THE TAB, not clamped to a
   vw. cbnight's `min(24rem, 86vw)` plus a 3.6rem tab was 393px at 390 — the
   open tab sat 3px off the left edge, measured. `--ax-drawer-w` here can
   never exceed the room the tab leaves, whatever the consumer sets it to.

   The consumer owns: the thumbnail (any <svg> it puts in `.ax-drawer-thumb`
   is sized to fill it), the panel's contents, the toggle (add `.is-open`,
   mirror `aria-expanded` on the tab), and `--ax-drawer-top` — the drawer
   sits below whatever THIS page keeps sticky (header + its own strips), and
   only the page knows that height — and `--ax-drawer-bottom` when its footer
   is taller than the shell's. Default: just under the shell header.

   <aside class="ax-drawer" hidden>
     <button type="button" class="ax-drawer-tab" aria-expanded="false" aria-controls="p">
       <span class="ax-drawer-thumb" aria-hidden="true"><svg …></svg></span>
       <span class="ax-drawer-tab-l">Map</span>
     </button>
     <div class="ax-drawer-panel" id="p" role="dialog" aria-modal="false" aria-label="…">
       <div class="ax-drawer-head"><p class="ax-eyebrow">…</p>
         <button type="button" class="ax-iconbtn" aria-label="Close">×</button></div>
       …
     </div>
   </aside> */
.ax-drawer {
  --ax-drawer-tab-w: 3.5rem;
  --ax-drawer-w: min(var(--ax-drawer-max, 23rem), calc(100vw - var(--ax-drawer-tab-w) - 0.5rem));
  --ax-drawer-top: calc(var(--shell-head-h, 3.4rem) + 0.9rem);
  --ax-drawer-bottom: var(--shell-foot-h, 2.6rem); /* the page's own fixed footer, if taller */
  position: fixed;
  top: var(--ax-drawer-top);
  right: 0;
  z-index: 15; /* over the page and the fixed footer (10), under the header (20) */
  display: flex;
  align-items: flex-start;
  max-height: calc(100dvh - var(--ax-drawer-top) - var(--ax-drawer-bottom) - 1rem);
  transform: translateX(var(--ax-drawer-w));
  transition: transform 0.22s ease;
}
.ax-drawer[hidden] {
  display: none;
}
.ax-drawer.is-open {
  transform: translateX(0);
}
@media (prefers-reduced-motion: reduce) {
  .ax-drawer {
    transition: none;
  }
}
.ax-drawer-tab {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.2rem;
  width: var(--ax-drawer-tab-w);
  min-height: 2.75rem;
  padding: 0.45rem 0.3rem 0.5rem;
  cursor: pointer;
  color: var(--subdued);
  background: var(--surface);
  border: var(--ax-line);
  border-right: 0;
  border-radius: var(--ax-r) 0 0 var(--ax-r);
  box-shadow: var(--ax-lift);
}
.ax-drawer-tab:hover,
.ax-drawer-tab:focus-visible {
  color: var(--text);
  border-color: var(--accent);
  outline: none;
}
.ax-drawer-tab-l {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}
.ax-drawer-thumb {
  display: block;
  width: 2.4rem;
  height: 1.8rem;
  overflow: hidden;
}
/* Clipped, not visible: real geometry plus a stroke sits fractionally outside
   its own viewBox, and at tab size that reads as a smudge on the button. */
.ax-drawer-thumb > svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
.ax-drawer-panel {
  flex: 0 0 auto;
  width: var(--ax-drawer-w);
  max-height: inherit;
  overflow-y: auto;
  padding: 0.9rem 1.1rem 1.2rem;
  background: var(--surface);
  border: var(--ax-line);
  border-right: 0;
  box-shadow: var(--ax-lift);
}
.ax-drawer-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 0.6rem;
}
.ax-drawer-head > .ax-eyebrow {
  margin: 0;
}
.ax-drawer-panel > svg {
  display: block;
  width: 100%;
  height: auto;
}

/* ══ List — tappable rows ═════════════════════════════════════════════════
   A roster, a directory, search results. Each row is ONE link (`<a>`), the
   whole row is the target (44px floor), and it says three things at most: a
   title, one quiet line, and a trailing mark ONLY where attention is needed —
   a signed arbitration is the normal state and gets nothing, so the marks that
   remain mean something. Hairlines between rows, never frames.
   `data-paginate` works here as on a table: shellPaginate and shellFilter
   treat the list's children as its rows. */
.ax-list {
  display: grid;
}
.ax-row {
  display: grid;
  /* ⚠️ THE TRAILING SLOT CANNOT TAKE THE ROW. `auto` gave it whatever its
     widest line asked for, and cbsvi stacked precinct + party + a claimant
     tag in it — 230 of 358px at 390 — so the TITLE ellipsised to "David S…"
     on exactly the rows a rep must read (cbsvi#96). Half the row is the
     ceiling, and it is on the TRACK: a percentage max-width on the ITEM is
     cyclic during track sizing, so the track went to full max-content and
     the title measured 2px. A slot that needs more than half is carrying
     something that belongs on the subtitle line. */
  grid-template-columns: minmax(0, 1fr) fit-content(50%);
  align-items: center;
  column-gap: 0.75rem;
  min-height: 44px;
  box-sizing: border-box;
  padding: var(--shell-row-y, 0.5rem) 0.25rem;
  border-top: 1px solid color-mix(in srgb, var(--line) 45%, transparent);
  color: var(--text);
  text-decoration: none;
}
.ax-row:first-child {
  border-top: 0;
}
.ax-row:hover {
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.ax-row[aria-current] {
  box-shadow: inset 3px 0 0 var(--accent);
  padding-left: 0.65rem;
}
.ax-row-t {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: 700;
}
.ax-row-s {
  grid-column: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--ax-fs-xs);
  line-height: 1.4;
  color: var(--subdued);
}
/* A quiet line that must be READ wraps (cbapex#168; three apps hand-rolled
   this over the ellipsis): a findings reason, a roster's groups. */
.ax-row-s.is-wrap {
  white-space: normal;
  overflow-wrap: anywhere;
  text-overflow: clip;
}
.ax-row-e {
  grid-column: 2;
  grid-row: 1 / span 2;
  display: flex;
  align-items: center;
  gap: 0.35rem;
  /* min-width 0 + overflow hidden: the item's automatic minimum is 0, so the
     fit-content(50%) track above really caps at half; wrap keeps a stacked
     slot readable inside that instead of clipping it. */
  min-width: 0;
  overflow: hidden;
  flex-wrap: wrap;
  justify-content: flex-end;
}
/* The stat tile's two optional slots (the dataviz contract: label · value ·
   delta · sparkline). The DELTA carries direction by glyph AND word — "▲ 12%
   vs 12-mo median" — so colour only reinforces it; the SPARK is shellSpark()
   sized by CSS to the tile. */
.ax-stat-d {
  display: inline-block;
  margin-top: 0.35rem;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.04em;
  color: var(--subdued);
}
.ax-stat-d.is-ok {
  color: var(--green);
}
.ax-stat-d.is-warn {
  color: var(--amber);
}
.ax-stat-d.is-bad {
  color: var(--red);
}
.ax-stat-spark {
  margin-top: 0.6rem;
}

/* ══ Pad — a grid of places to go, each a mark and a name ═════════════════
   Promoted from the Apps switcher (operator, 2026-10-11: "something that
   should be centralized"). The switcher's dialog draws with it, and any page
   that offers a set of destinations (apps, tools, workspaces) uses the same
   tiles rather than inventing a launcher.

   A tile is a LINK: a mark in a tinted well and a label beside it. The label
   carries the name, so a missing mark (`visibility: hidden` on error) leaves
   the column straight instead of breaking the row. `ax-pad-g` stands in for a
   mark with one glyph or letter, in the same well, so a pad that mixes the two
   still lines up in one column.

   Two-up by default: that is the dialog's measure (32rem) and a phone's.
   `ax-pad-fill` fills a page's width with as many 12rem columns as fit. An
   `ax-pad-h` spans the grid, so one pad can carry its own regions.
   ⚠️ `aria-current="page"` greys the tile and is NOT a link target any more;
   the switcher omits the current app instead (apex.ts), so that state is for
   a pad used as a map. */
.ax-pad {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.1rem 0.5rem;
  margin: 0.2rem 0 0.7rem;
}
.ax-pad-fill {
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
}
.ax-pad-h {
  grid-column: 1 / -1;
  margin: 0.5rem 0 0.2rem;
  padding: 0 0.45rem;
  font-family: var(--ax-mono, ui-monospace, monospace);
  font-size: var(--ax-fs-micro, 0.6875rem);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-pad-h:first-child {
  margin-top: 0;
}
.ax-pad-a {
  display: flex;
  /* Centred on the label: the row's height is whichever of the two is taller,
     and a 2.25rem well beside one line of type reads as proud when the pair is
     top-aligned. */
  align-items: center;
  gap: 0.55rem;
  min-height: 2.75rem; /* the 44px touch floor, before the well already reaches it */
  padding: 0.35rem 0.45rem;
  border-radius: 0.4rem;
  color: var(--text);
  text-decoration: none;
  font-size: var(--ax-fs-sm, 0.875rem);
  line-height: 1.2;
}
.ax-pad-a:hover,
.ax-pad-a:focus-visible {
  background: color-mix(in srgb, var(--accent) 16%, transparent);
}
.ax-pad-a > span {
  min-width: 0;
  overflow-wrap: anywhere;
}
/* The well: every mark at ONE size whatever the artwork, on a wash of the
   reader's accent (operator, 2026-09-04: "chonkier", "stand out a little
   more"). It brightens with the row, so the mark is part of the hover. */
.ax-pad-a > img,
.ax-pad-g {
  flex: 0 0 auto;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0.22rem;
  border-radius: 0.5rem;
  background: color-mix(in srgb, var(--accent) 13%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--line) 60%, transparent);
}
.ax-pad-a > img {
  object-fit: contain;
}
.ax-pad-g {
  display: grid;
  place-items: center;
  font-size: var(--ax-fs-md, 1rem);
  font-weight: 600;
  line-height: 1;
  color: var(--accent);
}
.ax-pad-a:hover > img,
.ax-pad-a:focus-visible > img,
.ax-pad-a:hover > .ax-pad-g,
.ax-pad-a:focus-visible > .ax-pad-g {
  background: color-mix(in srgb, var(--accent) 24%, transparent);
}
.ax-pad-a[aria-current="page"] {
  color: var(--subdued);
  cursor: default;
}

/* ══ Files — documents on a shelf (cbapex#97) ═════════════════════════════
   A stack of documents, each row: a category token (`ax-kind` / `ax-tag`), a
   filename that ellipsises, an optional date, and one or two ICON-ONLY actions
   (open / remove). The 2026-08-28 sweep found this row declared FOUR times in
   one app under four names — its own CSS said "three families that are the same
   row in three places", and a fourth had joined — with the icon button's reset
   repeated as an inline `style` on six call sites. cbadmin's personnel records
   and cbrural's rate cards are the next consumers.

   `.ax-files` is the stack (it carries the vertical margin, so a following
   `.ax-attach` row sits 0.5rem below it). `.ax-filerow` is the row and may be an
   `<a>` — the whole row is then the link and hover lifts its border. Distinct
   from `ax-row` (§ List) by material: a list row is a hairline-separated TAP
   TARGET; a file row is a small framed OBJECT with actions on it.
   ⚠️ `--shell-row-y`: these are rows, not table cells. */
.ax-files {
  display: grid;
  gap: 0.35rem;
  margin: 0.5rem 0;
}
.ax-files[hidden] {
  display: none;
}
.ax-filerow {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  min-width: 0;
  padding: var(--shell-row-y, 0.45rem) 0.6rem;
  background: var(--space, var(--surface3));
  border: var(--ax-line);
  border-radius: var(--ax-r-tight);
  font-size: var(--ax-fs-sm);
  color: var(--text);
  text-decoration: none;
}
.ax-filerow[hidden] {
  display: none;
}
a.ax-filerow:hover {
  border-color: var(--accent);
}
/* The name takes the slack so the date and the actions stay right-aligned;
   `min-width: 0` is what lets a long name ellipsise instead of pushing the
   buttons off a 390px row. */
.ax-filerow-name {
  flex: 1 1 8rem;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* PROSE in the name slot (cbapex#168). The ellipsis above is right for a FILE
   name and wrong for a sentence: Feedback put the tester's note there and every
   report in the family read "Got an error again when I tried to write. I a…",
   with no way to the rest. `is-prose` wraps and clamps at three lines;
   `is-open` is the whole text. The page decides whether a tap is needed — a
   note that fits is not a button. */
.ax-filerow-name.is-prose {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  white-space: normal;
  overflow-wrap: anywhere;
  text-overflow: clip;
  line-height: 1.4;
}
.ax-filerow-name.is-prose.is-open {
  -webkit-line-clamp: none;
  line-clamp: none;
  overflow: visible;
}
.ax-filerow-name.is-prose[hidden] {
  display: none;
}
.ax-filerow-name.is-prose[role="button"] {
  cursor: pointer;
}
.ax-filerow-when {
  flex: 0 0 auto;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  color: var(--subdued);
}
/* ⚠️ THE ACTIONS TRAVEL TOGETHER, AND A ROW WITH ACTIONS IS TWO LINES ON A
   PHONE. Measured at 390 before this wrapper existed: `id · Passport · Jan 4,
   2026 · [open]` filled the first line and the trash icon wrapped ALONE onto a
   second — a 97px row whose two actions sat on different lines, one of them
   with nothing beside it. `.ax-filerow-act` is a non-wrapping cluster pushed
   right, so the pair cannot be split; below 40rem the NAME takes the top line
   at full width and the token, date and actions sit beneath it. Measured after:
   77px, both actions together, every row in the stack the same shape.

   ⚠️ The cost is deliberate and worth stating: a one-action row that used to
   fit on one line (53px) is now two (77px) at 390. Uniform beats compact in a
   stack — a list of documents where some rows are one line and some are two
   reads as a rendering fault. Rows with no action cluster (a link row, a row
   with one text button) are unaffected and stay on one line. */
.ax-filerow-act {
  display: inline-flex;
  align-items: center;
  gap: 0.1rem;
  flex: 0 0 auto;
  margin-left: auto;
}
@media (max-width: 40rem) {
  .ax-filerow:has(.ax-filerow-act) > .ax-filerow-name {
    order: -1;
    flex-basis: 100%;
  }
}
/* A TEXT button in a file row ("Discard") is compact by CONTAINER — the same
   doctrine as `.ax-notice-act .ax-btn`; there is no standalone compact class. */
.ax-filerow > .ax-btn {
  min-height: 2.25rem;
  padding: 0.35rem 0.7rem;
  font-size: var(--ax-fs-sm);
}

/* ══ Query — the filters above a list (cbapex#168) ════════════════════════
   The most repeated hand-rolled shape left in the family: a box of selects,
   pill groups and switches over a table, with a count. cbprospect wrote it
   five times as `pr-query`, cbvmod, cbrural, cbconnector and the kit's own
   Users screen each wrote another. Its parts were already the kit's
   (`ax-field`, `ax-filters`, `ax-switch`); the PANEL was nobody's.

   ⚠️ WHY IT FOLDS ON A PHONE. Measured on cbprospect's Candidates at 390: the
   hero and seven controls filled the first 844px and the first person started
   below the fold — the filters were the page. So at phone width the panel is
   ONE LINE, "Filters · 2 on · 412 prospects", and the list is on the first
   screen. It says how many filters are ON while folded, because a folded
   panel hiding an active filter is how an empty list gets reported as a bug.
   From 40rem up it is always open and the line is a heading, not a control.

   It is the UA's <details>, so it works with no script; shell-controls.js
   keeps it open on a wide screen, remembers the reader's choice across a
   re-render (these pages redraw on every change), and counts what is on.

       <details class="ax-query" id="cand-filters">
         <summary>Filters <span class="ax-query-on"></span>
           <span class="ax-query-n">412 prospects</span></summary>
         <div class="ax-query-body"> ax-field · ax-filters · ax-switch … </div>
       </details> */
.ax-query {
  background: var(--space, var(--surface3));
  border: var(--ax-line);
  border-radius: var(--ax-r);
  padding: 0 var(--shell-box-x, 1rem);
}
.ax-query > summary {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.25rem 0.6rem;
  min-height: 2.75rem;
  /* The count wraps under the title at 390 when it carries a timestamp; the
     padding is what keeps that second line off the border. */
  padding: 0.3rem 0;
  box-sizing: border-box;
  cursor: pointer;
  list-style: none;
  font-weight: 700;
  color: var(--text);
}
.ax-query > summary::-webkit-details-marker {
  display: none;
}
.ax-query > summary::before {
  content: "";
  flex: none;
  width: 0.45em;
  height: 0.45em;
  border-right: 2px solid var(--accent);
  border-bottom: 2px solid var(--accent);
  transform: rotate(-45deg);
  transition: transform 0.15s ease;
}
.ax-query[open] > summary::before {
  transform: rotate(45deg);
}
.ax-query > summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* How many are on. Empty when none: an "0 on" chip is a number that is always
   there and therefore never read. */
.ax-query-on {
  font-family: var(--ax-mono);
  font-size: var(--ax-label-size);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: var(--ax-r-tight);
  padding: 0.05rem 0.4rem;
}
.ax-query-on:empty {
  display: none;
}
/* What the query found — the unfiltered truth stays at the top, in view
   whether the panel is folded or not. */
.ax-query-n {
  margin-left: auto;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  font-weight: 400;
  letter-spacing: 0.06em;
  color: var(--subdued);
}
/* A button in the count slot ("Refresh") is compact by CONTAINER, the same
   doctrine as `.ax-notice-act .ax-btn`: full size it wrapped the line and the
   folded panel measured 84px instead of 48. */
.ax-query-n .ax-btn {
  min-height: 1.75rem;
  margin-left: 0.4rem;
  padding: 0.2rem 0.6rem;
  font-size: var(--ax-fs-xs);
}
.ax-query-body {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: 0.65rem 1rem;
  padding: 0.2rem 0 var(--shell-box-y, 0.8rem);
}
.ax-query-body > * {
  margin-top: 0;
}
@media (max-width: 40rem) {
  /* One control a line: a select beside a select at 390 is two 160px boxes
     with their labels cut. */
  .ax-query-body > .ax-field {
    flex: 1 1 100%;
  }
}
@media (min-width: 40.01rem) {
  .ax-query > summary {
    cursor: default;
    min-height: 2.25rem;
  }
  .ax-query > summary::before {
    display: none;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ax-query > summary::before {
    transition: none;
  }
}

/* ══ Range — from one value to another (cbapex#168) ══════════════════════
   Two boxes and a word between them: a score from 40 to 70, a flight from the
   3rd to the 17th. Three apps wrote it (cbvmod's `vf-range` for a number,
   cbadmin and cbrural for dates), each with its own width rule and none with
   a way to say the pair is upside down.

   ⚠️ TWO BOXES, NOT A SLIDER — cbvmod's finding, kept: a two-handle slider is
   fiddly under a thumb at 390, cannot be typed, and "40 to 70" is something
   people say as digits. EITHER BOX ALONE IS A VALID QUESTION ("at least 40"),
   so an empty box is never an error.

   ⚠️ `min-width: 0` on the boxes is load-bearing: a number or date input's
   intrinsic width is wider than half a phone column, and without it the pair
   sets the page width.

   shell-controls.js keeps the pair honest: the clear button is disabled while
   both are empty, and a pair that ends before it starts says so in words
   (`ax-range-why`) and marks the second box `aria-invalid` — it is never
   silently swapped, because the reader may be half way through typing.

       <div class="ax-range" role="group" aria-label="Turnout score">
         <input class="ax-input" type="number" aria-label="Turnout score, from" />
         <span class="ax-range-to" aria-hidden="true">to</span>
         <input class="ax-input" type="number" aria-label="Turnout score, to" />
         <button class="ax-iconbtn ax-range-clear" type="button"
                 aria-label="Clear turnout score">×</button>
       </div> */
.ax-range {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3rem 0.4rem;
  min-width: 0;
}
.ax-range > input {
  flex: 1 1 0;
  min-width: 0;
  width: auto;
}
/* A date cannot squeeze: under min-width 0 a phone clipped the year to
   "10/03/202" (measured at 390, 2026-10-09). Held to its content, the pair
   wraps instead — the clear button drops to its own line before a digit does. */
.ax-range > input[type="date"] {
  min-width: 9rem;
}
.ax-range > input[type="number"] {
  text-align: center;
  font-variant-numeric: tabular-nums;
}
.ax-range-to {
  flex: 0 0 auto;
  font-size: var(--ax-fs-xs);
  color: var(--subdued);
}
.ax-range-clear {
  flex: 0 0 auto;
}
.ax-range-clear[disabled] {
  opacity: 0.35;
  cursor: default;
}
/* Its own line under the pair, and only when there is something to say. */
.ax-range-why {
  flex: 1 0 100%;
  font-size: var(--ax-fs-xs);
  font-weight: 700;
  color: var(--red);
}
.ax-range-why:empty {
  display: none;
}
.ax-range.is-bad > input[aria-invalid="true"] {
  border-color: var(--red);
}

/* ══ Groups — conditions, combined (cbapex#168) ══════════════════════════
   "Match ALL of these, or ANY of those." Three hand-rolled copies in two apps:
   cbvmod's audience builder, cbsvi's copy of it, and cbvmod's voter filters,
   which draw the same groups as a summary. The kit holds the SHAPES. What a
   condition is — its fields, its operators, its values — stays the app's.

   Four rules the copies paid for, kept:
   ⚠️ THE WORDS ARE ALL AND ANY, NEVER AND / OR, and the legend names no
   colour. One copy's legend read "Blue = ALL · Orange = ANY" and was false
   under every theme whose accent is not blue.
   ⚠️ THE JOINER SITS BETWEEN THE THINGS IT JOINS. One switch in a corner
   above the list is the shape people read backwards.
   ⚠️ ANY IS TOLD FROM ALL BY SHAPE: a dashed edge on the group and a hollow
   marker on its list. The difference between "all of these" and "any of
   these" is the whole point, and colour alone loses it.
   ⚠️ A NOTE UNDER A CONDITION TAKES THE FULL LINE (`ax-cond-note`). As a
   sibling of the selects in a wrapping row it squeezes the value box, and at
   390 the row is already at its limit.

       <div class="ax-groups">
         <section class="ax-group is-any"> … <div class="ax-cond">…</div> … </section>
         <div class="ax-group-join"><span class="ax-logic">…</span></div>
         <section class="ax-group is-active"> … </section>
       </div> */
.ax-groups {
  display: grid;
  gap: 0.5rem;
}
.ax-group {
  display: grid;
  gap: 0.45rem;
  min-width: 0;
  padding: 0.6rem 0.75rem 0.6rem 0.9rem;
  border-left: 3px solid var(--accent);
  border-radius: 0 var(--ax-r-tight) var(--ax-r-tight) 0;
}
.ax-group[hidden] {
  display: none;
}
.ax-group.is-any {
  border-left-style: dashed;
}
/* The one being edited, where only one group's controls are on screen: a
   surface as well as the word "editing" beside its name. */
.ax-group.is-active {
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.ax-group-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem 0.5rem;
  font-size: var(--ax-fs-sm);
  color: var(--subdued);
}
.ax-group-head > b {
  color: var(--text);
}
.ax-group-head > .ax-btn {
  min-height: 1.75rem;
  margin-left: auto;
  padding: 0.2rem 0.6rem;
  font-size: var(--ax-fs-xs);
}
/* What a group holds, in words: a summary that stays on screen while another
   group is being edited. */
.ax-group-bits {
  display: grid;
  gap: 0.15rem;
  margin: 0;
  padding-left: 1.1rem;
  font-size: var(--ax-fs-sm);
}
.ax-group.is-any .ax-group-bits {
  list-style: circle;
}
.ax-group-join {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}
.ax-group-join::before,
.ax-group-join::after {
  content: "";
  flex: 1;
  height: 1px;
  background: color-mix(in srgb, var(--line) 60%, transparent);
}
/* ALL | ANY — a pair of the kit's own pressable chips, joined. */
.ax-logic {
  display: inline-flex;
  gap: 0.2rem;
  vertical-align: middle;
}
.ax-logic .ax-filter {
  padding: 0.2rem 0.55rem;
  font-size: var(--ax-fs-micro);
}
/* One condition: a field, how it is compared, a value, and a way to remove
   it. It wraps; each part may shrink. */
.ax-cond {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
}
.ax-cond[hidden] {
  display: none;
}
.ax-cond > select,
.ax-cond > input,
.ax-cond > .ax-multi,
.ax-cond > .ax-range {
  flex: 1 1 10rem;
  min-width: 0;
}
.ax-cond > .ax-iconbtn {
  margin-left: auto;
}
.ax-cond-note {
  flex: 1 1 100%;
  /* A flex item will not shrink below its longest word, and a note names
     columns: measured 506px in a 329px row, 150px of sideways page. */
  min-width: 0;
  overflow-wrap: anywhere;
  margin: 0;
  padding-left: 0.5rem;
  border-left: 2px solid color-mix(in srgb, var(--line) 70%, transparent);
  font-size: var(--ax-fs-xs);
  line-height: 1.4;
  color: var(--subdued);
}

/* ══ Lookup — a field that holds an id, not the text (cbapex#168) ═════════
   A search box that offers a list: a client, a partner, a list. What is sent
   is the ID of the choice. Text that was typed and never chosen is DISCARDED,
   and the reader is told so before they submit, under the field, in words.
   Promoted from cbconnector's `cn-pick`.

   ⚠️ THE STATE IS A WORD AND A GLYPH; colour only reinforces it.
   ⚠️ THE LINE KEEPS ITS HEIGHT WHEN EMPTY, so the form does not jump on the
   first keystroke.
   ⚠️ `is-unset` alone is the plain warning under a field, for a select whose
   options did not load.

       <div class="ax-field">
         <label for="c">Client</label>
         <input id="c" class="ax-lookup" data-lookup="client" list="c-dl"
                autocomplete="off" aria-describedby="c-st">
         <datalist id="c-dl"><option value="Acme 72%" data-id="rec1"></option></datalist>
         <span class="ax-field-state" id="c-st" role="status"></span>
       </div> */
/* A label longer than the box is CUT WITH A MARK. What tells two choices
   apart is often at the end of the label (a rate, a year), and a silent clip
   makes them read the same. Outside an `ax-field`, add `ax-input`. */
.ax-lookup {
  text-overflow: ellipsis;
}
.ax-field-state {
  display: block;
  min-width: 0;
  min-height: 1.4em;
  font-family: var(--ax-mono);
  font-size: var(--ax-label-size);
  line-height: 1.4;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--subdued);
  overflow-wrap: anywhere;
}
.ax-field-state[hidden] {
  display: none;
}
.ax-field-state.is-set {
  color: var(--green);
}
.ax-field-state.is-set::before {
  content: "✓ ";
}
.ax-field-state.is-unset {
  color: var(--amber);
  font-weight: 700;
}
.ax-field-state.is-unset::before {
  content: "! ";
}

/* ══ Secret — a value that is on file, and hidden until asked (cbapex#168) ═
   A bank account, a routing number, a donor's name. Promoted from
   cbconnector's `cn-secret` / `cn-reveal`; cbhq's one-way contact unlock is
   the same row with `data-once`.

   ⚠️ MASKED, NOT OMITTED. Hiding the field entirely reads as "nothing on
   file", which is a different fact. cbconnector's rule, kept.
   ⚠️ THE KIT NEVER HOLDS THE VALUE. A tap fires `ax-reveal` and the PAGE
   answers, from memory or from its server. Hidden again, the value leaves the
   document: a masked value that sits in an attribute is not masked.
   ⚠️ THE MASK IS A FIXED RUN OF DOTS, or the masked form the server sent
   ("••••1234"). One dot per character gives away the length.
   ⚠️ NOTHING ABOUT THE VALUE REACHES A LOG. The event carries the key, never
   the value, and the shell writes it with textContent and says nothing.

       <span class="ax-secret" data-secret="routing_number">
         <span class="ax-secret-v">••••••••</span>
         <button class="ax-btn ax-btn-ghost" type="button">Reveal</button>
       </span> */
.ax-secret {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.2rem 0.5rem;
  min-width: 0;
  max-width: 100%;
}
.ax-secret[hidden] {
  display: none;
}
.ax-secret-v {
  min-width: 0;
  /* VERBATIM. Rendered inside an `ax-delta-n`, an email came out in capitals:
     the container's label styling is inherited, and a value in the wrong case
     is a wrong value. */
  text-transform: none;
  font-family: var(--ax-mono);
  letter-spacing: 0.15em;
  color: var(--subdued);
  overflow-wrap: anywhere;
  user-select: none;
}
.ax-secret.is-shown > .ax-secret-v {
  letter-spacing: 0;
  color: var(--text);
  white-space: pre-line;
  user-select: text;
}
/* A compact container, like the worklist's action slot: the button sits
   beside one value in a row of them. 32px, above the 24px AA floor. */
.ax-secret > .ax-btn {
  min-height: 2rem;
  padding: 0.2rem 0.6rem;
  font-size: var(--ax-label-size);
}

/* ══ Rail — where an object is in its lifecycle (cbapex#168) ═════════════
   A thin bar down the edge of an object — a message, an invite, a deploy, an
   ingot — that fills as the object moves through a KNOWN SEQUENCE. Promoted
   from cbsitrep's `msg-rail`, whose reasoning is the component's:

   ⚠️ IT IS NOT A SPINNER AND NOT A CAPTION. The kit allows exactly one
   spinner (the busy portal). This is durable state on an object that already
   exists: status as a property of the thing, not an announcement about it.
   ⚠️ QUIET WHEN EVERYTHING IS FINE. cbsitrep's first cut lit the rail on every
   inbound message and the thread shouted; the accent on every object means
   nothing. An object with nothing to report wears the plain rail.
   ⚠️ PROVISIONAL IS HATCHED, the meter's own idiom: a state that has not
   settled is a number you must not quote, and a tint does not survive a
   screenshot or a colourblind reader.
   ⚠️ ONLY FOR A SEQUENCE THAT MOVES FORWARD (cbsvi's agent, the day this
   shipped). A status DERIVED from an append-only history can go backwards —
   a voter who answered "yes, both" and later "would talk" — and a rail that
   un-fills reads as a bug even when it is the truth. That is a reading, not a
   position: it is a word (`ax-state`), not a rail.
   ⚠️ THE RAIL NEVER SPEAKS ALONE. Fill and colour are the second cue; the
   state is also a WORD on the object (`ax-state`), and the rail carries an
   `aria-label`.

   A <span> is the mark alone, 3px. A <button> is the same mark with a HIT
   AREA: 24px wide and the object's full height, because the original was a
   3px button and nobody could tap it. It discloses the object's trace —
   `aria-controls` names it and shell-controls.js toggles it.

       <div class="ax-railed">
         <button class="ax-rail is-ok" type="button" data-step="3" data-of="4"
                 aria-controls="t1" aria-expanded="false"
                 aria-label="Sent — show the delivery trace"></button>
         <div> … the object … <div id="t1" class="ax-rail-trace" hidden>…</div></div>
       </div> */
.ax-railed {
  display: flex;
  align-items: stretch;
  gap: 0.55rem;
  min-width: 0;
}
.ax-railed[hidden] {
  display: none;
}
/* A flex item's floor is its content: without this a long token in the object
   pushes the row past the screen. */
.ax-railed > :not(.ax-rail) {
  flex: 1 1 auto;
  min-width: 0;
}
.ax-rail {
  position: relative;
  flex: 0 0 3px;
  width: 3px;
  align-self: stretch;
  min-height: 1.5rem;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  --ax-rail-c: var(--muted);
}
.ax-rail::before,
.ax-rail::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 3px;
  border-radius: 2px;
}
.ax-rail::before {
  top: 0;
  background: color-mix(in srgb, var(--line) 70%, transparent);
}
.ax-rail::after {
  height: var(--ax-rail-fill, 0%);
  background: var(--ax-rail-c);
  transition: height 0.3s cubic-bezier(0.2, 0.7, 0.3, 1), background 0.3s ease;
}
.ax-rail.is-accent {
  --ax-rail-c: var(--accent);
}
.ax-rail.is-ok {
  --ax-rail-c: var(--green);
}
.ax-rail.is-warn {
  --ax-rail-c: var(--amber);
}
.ax-rail.is-bad {
  --ax-rail-c: var(--red);
}
.ax-rail.is-prov::after {
  background: repeating-linear-gradient(
    -45deg,
    var(--ax-rail-c) 0 3px,
    color-mix(in srgb, var(--ax-rail-c) 30%, transparent) 3px 6px
  );
}
button.ax-rail {
  flex-basis: 1.5rem;
  width: 1.5rem;
  cursor: pointer;
}
button.ax-rail:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
button.ax-rail:hover::before {
  background: var(--line);
}
.ax-rail-trace {
  margin-top: 0.4rem;
  padding: 0.5rem 0.6rem;
  border: var(--ax-line);
  border-radius: var(--ax-r-tight);
  background: var(--space, var(--surface3));
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  line-height: 1.5;
  color: var(--subdued);
  overflow-wrap: anywhere;
}
.ax-rail-trace[hidden] {
  display: none;
}
@media (prefers-reduced-motion: reduce) {
  .ax-rail::after {
    transition: none;
  }
}

/* ══ Multi — choose several from a long list (cbapex#168) ═════════════════
   Promoted from cbvmod's `vf-multi`. The BUTTON is a control and wears the
   control's box (the same 2.75rem as `ax-select`), so it sits in an `ax-field`
   or an `ax-query` like any other; shellMulti() (shell.js) opens the family's
   dialog over it with a search and a checklist.

   ⚠️ Unset reads as a placeholder and set reads as content by WEIGHT as well
   as colour, so the difference survives for a reader who cannot separate the
   hues. The caret is CSS-drawn: a `data:` background is forbidden by the CSP.

   ⚠️ THE CHECKBOX IS SIZED EXPLICITLY. `.ax-field input` styles every input
   under a field as a text box, and cbvmod's first checklist rendered as a
   column of full-width grey slabs with no room left for their labels. */
.ax-multi {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  min-width: 0;
  max-width: 100%;
  min-height: 2.75rem;
  padding: 0.5rem 0.75rem;
  box-sizing: border-box;
  border: var(--ax-line);
  border-radius: var(--ax-r);
  background: var(--space, var(--surface3));
  box-shadow: var(--ax-sink);
  color: var(--text);
  font: inherit;
  font-size: var(--ax-fs-sm);
  text-align: left;
  cursor: pointer;
}
.ax-multi[hidden] {
  display: none;
}
/* Width only — no second `display`, which would out-specify the [hidden]
   guard. An inline-flex box takes a width like any other. */
.ax-field > .ax-multi {
  width: 100%;
}
.ax-multi::after {
  content: "";
  flex: none;
  width: 0.4em;
  height: 0.4em;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-0.12em) rotate(45deg);
  opacity: 0.7;
}
.ax-multi:hover {
  border-color: var(--accent);
}
.ax-multi:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.ax-multi[disabled] {
  opacity: 0.5;
  cursor: default;
}
.ax-multi-v {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--subdued);
}
.ax-multi-v.is-set {
  color: var(--text);
  font-weight: 700;
}
.ax-multi-q {
  width: 100%;
}
/* The list scrolls inside the dialog, so Search stays above it and Done below
   it however long the list is. */
.ax-multi-list {
  display: grid;
  gap: 0.1rem;
  max-height: min(48vh, 22rem);
  margin-top: 0.5rem;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.ax-multi-opt {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  min-height: 2.4rem;
  padding: 0.2rem 0.4rem;
  border-radius: var(--ax-r-tight);
  cursor: pointer;
}
.ax-multi-opt:hover {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.ax-multi-opt input[type="checkbox"] {
  flex: 0 0 auto;
  width: 1.15rem;
  height: 1.15rem;
  min-width: 0;
  min-height: 0;
  margin: 0;
  padding: 0;
  box-shadow: none;
  accent-color: var(--accent);
}
.ax-multi-opt span {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
  color: var(--text);
}
.ax-multi-note {
  margin: 0.4rem 0 0;
  font-size: var(--ax-fs-xs);
  color: var(--subdued);
}
.ax-multi-note:empty {
  display: none;
}
.ax-multi-n {
  align-self: center;
  margin-right: auto;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-xs);
  color: var(--subdued);
}

/* ══ Rolodex — an A–Z rail for a long page (cbapex#170) ═══════════════════
   Operator, 2026-09-28: "We have these progress pages, useful as long, run-on
   pages of info. Obviously on mobile these become hard to navigate. I want a
   new Widget Zoo type for a page rolodex." The mechanism is cbloom's launcher
   (`renderRolodex`), which was welded to its tiles; this is the rail alone.

   It indexes what is ALREADY ON THE PAGE. A row says what it is filed under
   (`data-rolodex="Abraham, Veronica"`), the rail names the list it serves
   (`data-for="reps"`, one id or several), and shell-controls.js draws the
   letters. Nothing is sorted and nothing is copied: a letter jumps to the
   first thing filed under it, in the page's own order.

   ⚠️ EMPTY LETTERS ARE DIMMED, NEVER REMOVED — the rail is the same shape on
   every page, so a thumb learns where M is.
   ⚠️ A JUMP OPENS WHAT IS FOLDED. A row the pager has paged out, or one inside
   a closed <details>, is revealed before the page scrolls to it; a rail that
   lands on nothing is worse than no rail.

       <nav class="ax-rolodex" aria-label="People, A to Z" data-for="reps"></nav>
       <div class="ax-list" id="reps">
         <a class="ax-row" data-rolodex="Abraham, Veronica" …> */
.ax-rolodex {
  position: sticky;
  top: calc(var(--shell-head-h, 3.4rem) + var(--shell-controls-h, 0px));
  z-index: 2;
  display: flex;
  /* ⚠️ 27 LETTERS ARE WIDER THAN A PHONE, AND THE RAIL MUST NOT SET THE PAGE'S
     WIDTH. It did, by 569px, on the kit's own Users screen: its <main> is a
     flex item, a flex item's floor is its content's min-content, and the
     rail's was 926px. `max-width: 100%` cannot help — the percentage is of a
     parent that has already grown. `width: 0; min-width: 100%` gives the rail
     NO intrinsic width to contribute and then fills whatever its parent is. */
  width: 0;
  min-width: 100%;
  box-sizing: border-box;
  gap: 0.15rem;
  margin: 0.5rem 0;
  padding: 0.25rem 0;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  background: var(--bg, var(--space));
  border-bottom: var(--ax-line);
}
.ax-rolodex::-webkit-scrollbar {
  display: none;
}
.ax-rolodex:empty {
  display: none;
}
.ax-rolodex-l {
  flex: 0 0 auto;
  min-width: 2rem;
  min-height: 2rem;
  padding: 0 0.3rem;
  border: 0;
  border-radius: var(--ax-r-tight);
  background: none;
  color: var(--text);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-xs);
  font-weight: 700;
  line-height: 2rem;
  text-align: center;
  cursor: pointer;
}
.ax-rolodex-l:hover {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.ax-rolodex-l:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
/* Where you are: the accent AND an underline. */
.ax-rolodex-l[aria-current] {
  color: var(--accent);
  box-shadow: inset 0 -3px 0 var(--accent);
}
.ax-rolodex-l[disabled] {
  color: color-mix(in srgb, var(--subdued) 45%, transparent);
  font-weight: 400;
  cursor: default;
}
.ax-rolodex-l[disabled]:hover {
  background: none;
}
/* BY SECTION: the entries are names, not letters — the body face, room either
   side, and never wrapped. The strip still scrolls sideways. */
.ax-rolodex[data-by="section"] .ax-rolodex-l {
  padding: 0 0.75rem;
  font-family: inherit;
  font-size: var(--ax-fs-sm);
  white-space: nowrap;
}
/* What the jump landed on: room above it for the sticky chrome and the rail,
   and a ring that fades — the page moved, so say where to. */
[data-rolodex],
.ax-sect-t {
  /* Exactly under the rail (2rem letters + its padding), not a row below it:
     with more room the row ABOVE the target showed under the rail and the
     rail marked the letter before the one that was tapped. */
  scroll-margin-top: calc(var(--shell-head-h, 3.4rem) + var(--shell-controls-h, 0px) + 2.75rem);
}
[data-rolodex].is-found {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
@media (max-height: 560px) {
  .ax-rolodex {
    position: static;
  }
}

/* ══ Disclosure — more, one tap away (cbapex#168) ═════════════════════════
   The UA's <details>, dressed: a summary that reads as a section title with a
   chevron that turns, and a body set off by a rule. No JS and no ARIA to keep
   in step — the element carries its own state, keyboard and find-in-page.
   Promoted from cbsvi's talking points; any "read more" that is not a dialog
   is this. ⚠️ For actions on a ROW use `ax-row-more`; this holds prose. */
.ax-more {
  margin: var(--ax-flow, 1rem) 0 0;
}
.ax-more > summary {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 2.4rem;
  cursor: pointer;
  list-style: none;
  font-weight: 700;
  color: var(--text);
}
.ax-more > summary::-webkit-details-marker {
  display: none;
}
.ax-more > summary::before {
  content: "";
  flex: none;
  width: 0.45em;
  height: 0.45em;
  border-right: 2px solid var(--accent);
  border-bottom: 2px solid var(--accent);
  transform: rotate(-45deg);
  transition: transform 0.15s ease;
}
.ax-more[open] > summary::before {
  transform: rotate(45deg);
}
.ax-more > summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.ax-more-body {
  margin: 0.25rem 0 0.25rem 0.2rem;
  padding-left: 0.8rem;
  border-left: 2px solid var(--accent);
  color: var(--text);
  line-height: 1.5;
}
@media (prefers-reduced-motion: reduce) {
  .ax-more > summary::before {
    transition: none;
  }
}

/* ══ Figures — a number or a code in running text (cbapex#168) ════════════
   Promoted from cbadmin's `bk-amt` / `bk-mono` / `bk-neg`; any money surface
   needs the same three. `ax-num` is a figure: tabular digits, never broken
   across lines. `is-code` is an identifier (an account's last four, a bank
   reference): the mono face, so it reads as a code and lines up down a
   column. `is-neg` is a negative — and ⚠️ THE SIGN IS IN THE TEXT (U+2212),
   the red is only the second cue; colour alone is not a minus.
   `ax-swatch` is a colour beside its hex (cbassay, cbnight): set the colour
   with `--ax-swatch`, never a literal in a class. */
.ax-num {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.ax-num.is-code {
  font-family: var(--ax-mono);
}
.ax-num.is-neg {
  color: var(--red);
}
.ax-swatch {
  display: inline-block;
  width: 1.1em;
  height: 1.1em;
  margin-right: 0.35em;
  border: 1px solid var(--line);
  border-radius: 0.25em;
  background: var(--ax-swatch, transparent);
  vertical-align: -0.2em;
}

/* ══ Spark — a trend, in a tile or a cell ══════════════════════════════════
   Drawn by shellSpark() (shell.js). The line is de-emphasised and the LAST
   point wears the state — that is the dataviz rule for a sparkline ("the
   series in the de-emphasis hue, the current period in the accent"). Nothing
   here is a literal colour. In a table cell it is inline; in a tile it is a
   block sized to the tile. */
/* Inline by default (a table cell); the tile slot below makes it a block. Both
   sit under the [hidden] guard's (0,2,0) — the first cut wrote
   `.data-table .ax-spark { display }` and tied the guard. */
.ax-spark {
  display: inline-block;
  vertical-align: middle;
  max-width: 100%;
  height: auto;
  overflow: visible;
}
.ax-stat-spark > svg {
  display: block;
  width: 100%;
}
.ax-spark polyline {
  fill: none;
  stroke: var(--subdued);
  stroke-width: 2;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.ax-spark rect {
  fill: color-mix(in srgb, var(--subdued) 70%, transparent);
}
.ax-spark-now {
  fill: var(--accent);
}
.ax-spark.is-ok .ax-spark-now {
  fill: var(--green);
}
.ax-spark.is-warn .ax-spark-now {
  fill: var(--amber);
}
.ax-spark.is-bad .ax-spark-now {
  fill: var(--red);
}
.ax-spark-base {
  stroke: color-mix(in srgb, var(--line) 80%, transparent);
  stroke-width: 1;
  stroke-dasharray: 3 3;
}

/* ══ Dialog — the family's modal, on the UA's <dialog> ═════════════════════
   Drawn by shellDialog (shell.js). ⚠️ NO `display` ON `.ax-dialog`: open and
   closed are the UA's, exactly the [popover] rule in the charter — an authored
   display shows a closed dialog inline. The panel is the same material as a
   card (2px line, lift), the backdrop is the busy portal's glass. A CARD at
   every width — see the phone block below for why there is no sheet. Head ·
   body (scrolls) · actions (pinned), so the buttons are always reachable
   however long the body grows. `.is-wide` for a wizard. */
.ax-dialog {
  /* ⚠️ STATED, because loom.css line 7 is `* { margin: 0 }` — a vendored reset
     that zeroes the `margin: auto` the UA uses to CENTRE a modal dialog. Measured
     on the first render: every dialog pinned to the top-left at 1440. Same file
     the charter names for box-sizing, opposite direction: the kit must neither
     lean on that reset nor be broken by it. */
  margin: auto;
  padding: 0;
  border: var(--ax-line);
  border-radius: var(--ax-r-loose, 10px);
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--ax-lift);
  width: min(32rem, calc(100vw - 2rem));
  max-height: min(85svh, 46rem);
  overflow: hidden;
}
.ax-dialog.is-wide {
  width: min(46rem, calc(100vw - 2rem));
}
/* The backdrop is the BUSY PORTAL's glass — the same tint and the same 3px
   (shell.css `.shell-busy`), because both mean "the page is not available
   right now" and a person should not learn two dimmings. ⚠️ `-webkit-` stated:
   iOS before 18 only honours the prefixed form, and the operator reviews on a
   phone. It used to be a 72% near-black scrim, which HIDES the page rather
   than holding it. */
.ax-dialog::backdrop {
  background: color-mix(in srgb, var(--bg, #171326) 38%, transparent);
  -webkit-backdrop-filter: blur(3px);
  backdrop-filter: blur(3px);
}
/* ⚠️ NO PERCENTAGE HEIGHT HERE (2026-09-07). This carried `height: 100%`
   against a top-layer <dialog> whose own height is content-sized with
   `margin: auto` — a percentage engines resolve differently (Chromium: auto;
   a phone reported the card and the Apps dialog as "a thin horizontal box",
   an open dialog with a zero-height inner). The cap alone is enough: the
   inner column inherits the dialog's max-height, the body scrolls inside it. */
.ax-dialog-in {
  display: flex;
  flex-direction: column;
  max-height: inherit;
  min-height: 0;
}
.ax-dialog-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  flex-wrap: wrap;
  padding: 0.85rem 1.1rem;
  border-bottom: var(--ax-line);
}
.ax-dialog-title {
  margin: 0;
  min-width: 0;
  font-size: var(--ax-fs-md);
  font-weight: 800;
  letter-spacing: -0.01em;
  line-height: 1.3;
}
.ax-dialog-body {
  flex: 1 1 auto;
  min-height: 0; /* a flex item's min-height is its content unless told otherwise — the scroll needs this */
  overflow-y: auto;
  padding: var(--shell-box-y, 0.9rem) var(--shell-box-x, 1.1rem);
  line-height: 1.55;
}
.ax-dialog-fields {
  display: grid;
  gap: 0.9rem;
  align-content: start;
}
/* A region heading inside a dialog (2026-09-07): the menu's recipe, so a long
   form or a result reads as its parts. First one sits at the top, the rest
   stand off the field above. */
.ax-dialog-h {
  margin: 0;
  font-family: var(--ax-mono, ui-monospace, monospace);
  font-size: var(--ax-fs-micro);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-dialog-fields .ax-dialog-h:not(:first-child) { margin-top: 0.5rem; }
.ax-dialog-body > .ax-dialog-h + * { margin-top: 0.4rem; }
.ax-dialog-body > * + .ax-dialog-h { margin-top: 1rem; }
/* A note inside a dialog carries prose the caller wrote — and sometimes a LIST
   ("these blocking checks are failing:" then a line each). Collapsing those to
   one run-on line loses the thing the note is for, so its newlines survive. */
.ax-dialog-note {
  white-space: pre-wrap;
}
.ax-dialog-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.5rem;
  padding: 0.75rem 1.1rem;
  border-top: var(--ax-line);
  background: var(--surface);
}
/* A wizard's row is Back on the left and Continue on the right. `.is-split`
   pushes the first child to the far edge — nine call sites in one app carried
   a spacer element to do this (2026-08-28 sweep). */
.ax-dialog-actions.is-split > :first-child {
  margin-right: auto;
}
/* The step strip of a wizard: numbered markers, the current one filled, the
   done ones outlined in the accent. Mono, small, never the page's focus. */
.ax-dialog-steps {
  display: flex;
  gap: 0.3rem;
}
.ax-dialog-step {
  display: grid;
  place-items: center;
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 999px;
  border: 2px solid color-mix(in srgb, var(--line) 70%, transparent);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  font-weight: 700;
  color: var(--subdued);
}
.ax-dialog-step.is-on {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  color: var(--text);
}
.ax-dialog-step.is-done {
  border-color: var(--accent);
  color: var(--accent);
}
/* ⚠️ A dialog is a CARD at every width — there is no full-screen sheet. There
   was one (`max-width: 40rem` → 100vw × 100svh), and it made a two-button
   confirm take the whole phone with nothing behind it; the operator's words
   were "too large". At 390px the card is 358px wide and content-tall, the
   blurred page visible around it, which is what says "you are still on the
   page". Height is capped below the viewport so the actions row stays
   reachable however long the body grows (the body scrolls, see .ax-dialog-in). */
@media (max-width: 40rem) {
  .ax-dialog,
  .ax-dialog.is-wide {
    width: calc(100vw - 2rem);
    max-height: 85svh;
  }
}

/* ══ Wizard ends — the first step and the last (cbapex#97) ═════════════════
   `ax-dialog-steps` above implies a flow, and the kit shipped neither end of
   one. cbvipulse built both — `wz-typepick` (step 1: "which flow?", one
   full-width button per branch) and `wz-done` (the terminal panel: a green
   check disc and one line saying what is now true) — and cbvmod's segment
   wizard is the second consumer of each.

   `.ax-pick` is a stacked grid of full-width buttons. Full width is the point:
   at 390 two side-by-side branch buttons wrap their labels, and a wizard's
   first choice should not be the step that needs a second look. The buttons
   carry their own tone (`ax-btn-primary` for the usual path, `ax-btn-danger`
   for the irreversible one) — the container adds none.

   `.ax-done` is a STATEMENT, not a decoration: its sentence says what is now
   true ("Notice sent via BoldSign · status: partially_signed") and the disc is
   a glyph beside it. ⚠️ Green is not the only signal — the ✓ is text, and the
   sentence carries the outcome, so a greyscale reader gets the same fact.
   ⚠️ DO NOT USE IT FOR A FAILURE. A refused final step is `.ax-blank.is-bad`
   (§ Empty and error): a ✓ disc over "could not send" is the lie this file's
   empty/error section exists to prevent. */
.ax-pick {
  display: grid;
  gap: 0.5rem;
}
.ax-pick[hidden] {
  display: none;
}
.ax-pick > .ax-btn {
  width: 100%;
}
.ax-done {
  display: grid;
  place-items: center;
  gap: 0.6rem;
  padding: 2rem 1rem;
  text-align: center;
}
.ax-done[hidden] {
  display: none;
}
.ax-done-mark {
  display: grid;
  place-items: center;
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  background: color-mix(in srgb, var(--green) 18%, transparent);
  color: var(--green);
  font-size: var(--ax-fs-xl);
  font-weight: 700;
}
.ax-done-t {
  margin: 0;
  font-weight: 800;
  font-size: var(--ax-fs-md);
}
.ax-done-s {
  margin: 0 auto;
  max-width: 48ch;
  line-height: 1.55;
  color: var(--subdued);
}
.ax-done-act {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  justify-content: center;
  margin-top: 0.4rem;
}

/* ══ Toast — a short, timed message ════════════════════════════════════════
   Drawn by shellToast() (shell.js). A stack at the BOTTOM, above the footer,
   where a thumb is; the same material as a card, the state on the left edge
   and — because colour alone is not a state — a mono state word leading the
   text. The host takes no pointer events so a stack never blocks a tap on the
   page beneath; each toast takes them back so a tap dismisses it. */
.ax-toasts {
  position: fixed;
  left: 0;
  right: 0;
  bottom: calc(var(--shell-foot-h, 3rem) + 0.75rem);
  z-index: 60;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  padding: 0 var(--shell-gutter, 1rem);
  pointer-events: none;
}
.ax-toast {
  pointer-events: auto;
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  width: min(32rem, 100%);
  padding: 0.7rem 0.9rem;
  border: var(--ax-line);
  border-left: 3px solid var(--ax-toast-c, var(--accent));
  border-radius: var(--ax-r);
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--ax-lift);
  font-size: var(--ax-fs-sm);
  line-height: 1.45;
  cursor: pointer;
  animation: ax-toast-in 0.18s ease-out;
  transition:
    opacity 0.25s,
    transform 0.25s;
}
.ax-toast.is-ok {
  --ax-toast-c: var(--green);
}
.ax-toast.is-warn {
  --ax-toast-c: var(--amber);
}
.ax-toast.is-bad {
  --ax-toast-c: var(--red);
}
.ax-toast.is-out {
  opacity: 0;
  transform: translateY(0.4rem);
}
.ax-toast > .ax-state {
  flex: none;
  padding-top: 0.1rem;
}
.ax-toast-t {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}
.ax-toast-n {
  flex: none;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  color: var(--subdued);
}
@keyframes ax-toast-in {
  from {
    opacity: 0;
    transform: translateY(0.5rem);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ax-toast {
    animation: none;
    transition: none;
  }
}

/* A stat card that also carries the rows it counts. The count and its label
   are the summary; the table underneath is the evidence. Only the direct child
   is spaced, so a nested scroller inside the table is unaffected. */
.ax-stat > .ax-scroller,
.ax-card > .ax-scroller {
  margin-top: 0.75rem;
}

/* ══ Meter — a measured quantity ═══════════════════════════════════════════
   A bounded rule, not a capsule. Square ends and a real border make it read as
   a gauge; rounded ends and no border make it read as a loading animation, and
   these numbers are measurements rather than progress. */
.ax-meter {
  height: 10px;
  min-width: 70px;
  border: var(--ax-line);
  border-radius: var(--ax-r-tight);
  background: var(--space, var(--surface3));
  overflow: hidden;
  position: relative;
}
.ax-meter > span {
  position: absolute;
  inset: 0 auto 0 0;
  background: var(--accent);
  border-radius: 0;
}
.ax-meter.is-full > span {
  background: var(--green);
}
.ax-meter.is-empty > span {
  background: var(--red);
}
/* ── Parts — one whole cut into pieces (cbapex#97) ──────────────────────────
   `.ax-meter` above is ONE value against a track. `.is-parts` is the same
   material carrying several: each `<i>` is a slice, sized by `width: %` at the
   call site and coloured by `--c`, the same custom property `.ax-stack` uses
   for a span. Two apps had built this — cbprospect's `sc-stack` (contacted /
   callable-not-yet / no-details) and cbnight's `exc-bar` (datasource status by
   registered voters) — and the promotion is `<i>` because that is what the
   older one used.

   ⚠️ THE SWEEP FILED THIS AS "the nested-subset bar" AND THAT IS THE DATA, NOT
   THE SHAPE. Both consumers hold quantities that CONTAIN one another
   (contacted ⊆ callable ⊆ total) and both render them as DISJOINT slices that
   sum to the whole — cbprospect's own code says so: *"Segments, not cumulative
   widths: each is the slice the one before it does not already contain."* The
   kit ships the shape, and the caller does that subtraction; a component that
   drew nested bars would serve neither of the two surfaces that asked for it.

   ⚠️ `min-width: 2px` IS LOAD-BEARING, and it is cbprospect's earned comment:
   *"A segment that rounds to nothing still has to be visible, or 'we have
   called three people' renders as 'we have called nobody'."* A 0.02% slice is
   0 physical pixels at any width a phone has.

   ⚠️ AN EMPTY TRACK AND A FULL GREY ONE ARE DIFFERENT STATEMENTS. When nothing
   has been counted, emit NO segments — the bare track means "not measured".
   Filling it with the inert colour says "all of them are unreachable", which
   is a claim about the world made out of an absence in the record.

   ⚠️ NEVER COLOUR-ONLY. A parts bar without `.ax-legend` under it is a chart
   whose only encoding is hue; every consumer of this shape has a legend
   because a 2px segment is unreadable AND unhoverable on a phone. Give the bar
   `role="img"` and an `aria-label` that states the figures in words. */
.ax-meter.is-parts {
  display: flex;
}
.ax-meter.is-parts > i {
  display: block;
  height: 100%;
  min-width: 2px;
  background: var(--c, var(--accent));
}
/* ⚠️ Written NEXT TO the display rules, and the gate is why. Both selectors
   above out-specify `components.css`'s namespace `[hidden]` guard — (0,2,0) and
   (0,2,1) against its (0,2,0) — so without these, `bar.hidden = true` drew the
   bar anyway and a hidden SEGMENT stayed on screen. `components-hidden.test.ts`
   named both by selector on the first app that vendored it; the four-line
   version of this trap has now cost a tab strip, an invite form, a sign-out
   button and a popover panel. */
.ax-meter.is-parts[hidden],
.ax-meter.is-parts > i[hidden] {
  display: none;
}

/* ══ Legend — what a colour means, and what it counts ═══════════════════════
   A swatch, the thing it names, and its figure. SIX hand-rolled versions of
   this existed when it was promoted (cbapex#97) — cbprospect's `sc-legs`, and
   five in cbnight alone: `nt-leg-row`, `exc-legend-item`, `elx-legend`,
   `ed-map-key`, plus the swatch classes each carried — which makes it the most
   duplicated single shape the 2026-08-28 sweep found.

   `--c` colours the swatch, the same property `.ax-meter.is-parts > i` and
   `.ax-stack` use, so a bar and its legend take one variable each and cannot
   drift apart. `.ax-legend-v` is `tabular-nums`: figures in a column that do
   not line up read as an unsorted list.

   ⚠️ A LEGEND ROW MUST SAY WHICH QUANTITY IT REPORTS, and the reason is a live
   defect this promotion found. cbprospect's third row wears the grey swatch of
   the "no contact details" SEGMENT while reporting "Ballot mapped" — a
   different quantity against a different denominator (`expected`, not
   `total`). A reader matching swatch to row reads that figure as the width of
   the grey slice. It is not. So: repeat the SEGMENT's own figure, or use
   `.ax-legend-s` to name the denominator you switched to. Filed on
   cbprospect#6; the component cannot prevent it, only make saying so cheap. */
.ax-legend {
  display: grid;
  gap: 0.15rem;
  margin-top: 0.5rem;
}
.ax-legend[hidden] {
  display: none;
}
.ax-legend-row {
  display: grid;
  grid-template-columns: 0.6rem minmax(0, 1fr) auto;
  align-items: baseline;
  gap: 0.15rem 0.45rem;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
}
.ax-swatch {
  width: 0.6rem;
  height: 0.6rem;
  border-radius: 2px;
  align-self: center;
  background: var(--c, var(--accent));
}
.ax-legend-n {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--subdued);
}
.ax-legend-v {
  text-align: right;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}
/* The denominator, or anything else that stops the figure above being
   misread. Sits under the name, in the name's column. */
.ax-legend-s {
  grid-column: 2 / -1;
  color: var(--muted, var(--subdued));
  font-size: var(--ax-fs-micro);
  line-height: 1.35;
}

/* ══ Chip — a small bordered token ═════════════════════════════════════════
   Outlined, never filled. loom.css fills its badges and hardcodes `#fff` on
   top, which fails contrast on any light accent (cbloom#321) — an outline puts
   the contrast back under our control and cannot inherit that bug. */
.ax-chip {
  display: inline-block;
  border: var(--ax-line);
  border-radius: var(--ax-r-tight);
  background: transparent;
  color: var(--subdued);
  font-family: var(--ax-mono);
  font-size: var(--ax-label-size);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 0.1rem 0.4rem;
}
.ax-chip.is-accent {
  border-color: var(--accent);
  color: var(--accent);
}
.ax-chip.is-ok {
  border-color: var(--green);
  color: var(--green);
}
.ax-chip.is-warn {
  border-color: var(--amber);
  color: var(--amber);
}
.ax-chip.is-bad {
  border-color: var(--red);
  color: var(--red);
}

/* ══ State — a word that is also a status ══════════════════════════════════
   Colour alone is not a state: it fails for anyone who cannot separate the
   hues, and it fails again beside another coloured thing. Weight and a mono
   face carry the meaning; the colour only reinforces it. */
.ax-state {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-state.is-ok {
  color: var(--green);
}
.ax-state.is-warn {
  color: var(--amber);
}
.ax-state.is-bad {
  color: var(--red);
}

/* ══ Empty and error are not the same thing ════════════════════════════════
   Both usually ship as quiet grey text, which is why a broken screen and an
   idle one look alike. Empty is a fact and stays quiet. An error is a REFUSAL,
   and this system paints refusals. */
.ax-empty {
  color: var(--subdued);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.ax-error {
  color: var(--red);
  border-left: 2px solid var(--red);
  padding-left: 0.7rem;
  font-weight: 600;
}
/* The CAUTION tier between the two: "this worked, here is the caveat". Same
   form as the refusal in the warning colour, so the three read as one scale.
   Five names in two apps did this job before the 2026-08-28 sweep. */
.ax-warn {
  color: var(--amber);
  border-left: 2px solid var(--amber);
  padding-left: 0.7rem;
  font-weight: 600;
}

/* ── Blank — the STATED empty screen (cbapex#97) ────────────────────────────
   `.ax-empty` above is an inline mono LABEL: uppercase, tracked, 0.72rem. That
   is right for "No match." in a list cell and wrong for a whole surface, and
   the kit was the worst offender in the family — `invites.html` rendered *"No
   invites yet. The first one you send shows up here."* through it, so a 52-
   character sentence came out ALL CAPS, TRACKED AND MONOSPACED, directly under
   a comment saying empty and error are different components. cbprospect had
   built the panel (`pr-empty`) and nobody else had one.

   The shape: a bordered, centred panel, a heading in the LABEL face, and one
   measured sentence at a readable measure. `.ax-blank-act` holds the one thing
   the reader can do about it, when there is one.

   ⚠️ `.is-bad` IS NOT DECORATION — IT IS THE DIFFERENCE BETWEEN WAIT AND ACT.
   An empty screen states a fact ("nothing here yet") and a reader is right to
   do nothing. A screen that REFUSED states that the app could not answer, and
   the reader should retry, sign in, or tell someone. Shipping both as the same
   quiet grey is why a broken screen and an idle one look alike — the sentence
   `.ax-empty` sits under, and which the kit's own profile.html then broke by
   rendering "Could not read your 2FA settings." as an empty fact.

   ⚠️ The BODY of a refusal must carry the reason, not just the tone. "Queue
   unavailable" in the heading with the upstream's own message beneath it is
   cbprospect's pattern and it is the right one: the colour says which kind of
   nothing this is, the sentence says what happened. */
.ax-blank {
  padding: 3rem 1.5rem;
  border: var(--ax-line);
  border-radius: var(--ax-r);
  background: var(--surface);
  text-align: center;
  color: var(--subdued);
}
.ax-blank[hidden] {
  display: none;
}
.ax-blank-t {
  margin: 0 0 0.5rem;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  font-weight: 700;
  letter-spacing: var(--ax-label-track);
  text-transform: uppercase;
  color: var(--accent);
}
.ax-blank.is-bad .ax-blank-t {
  color: var(--red);
}
/* A real sentence, so it gets sentence typography: no tracking, no caps, and a
   measure that stops at 56ch because a centred line longer than that loses the
   reader on the way back to the left edge. */
.ax-blank-s {
  margin: 0 auto;
  max-width: 56ch;
  line-height: 1.6;
  font-size: var(--ax-fs-sm);
  font-family: inherit;
  letter-spacing: normal;
  text-transform: none;
}
.ax-blank-act {
  margin-top: 1.1rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  justify-content: center;
}

/* ══ Hint — the quiet caption ═══════════════════════════════════════════════
   One small muted line under a control, beside a title, or as a working state
   inside a modal ("Analysing against the handbook…", role="status"). The kit
   had only the SCOPED versions (`ax-sect-s`, `ax-stat-s`, `ax-setting-s`); one
   app grew seven names for the standalone one and the kit's own stash page an
   eighth. Promoted by the 2026-08-28 sweep. */
.ax-hint {
  margin: 0.4rem 0 0;
  font-size: var(--ax-fs-xs);
  line-height: 1.5;
  color: var(--subdued);
}

/* ══ Nothing — three kinds of it ════════════════════════════════════════════
   "Absent is not zero" is a fleet rule, and rendering every nothing as one grey
   dash is how a gap in the record gets quoted as a fact about the world. Three
   tokens, from cbprospect's `pr-unknown`/`pr-none`/`pr-zero` and cbvmod's
   `ol-zero`/`rh-na` (2026-08-28 sweep):
     - UNKNOWN  not measured — italic, muted; carry the reason in `title`
     - NONE     measured, and empty — mono, quiet (the same face as ax-empty)
     - ZERO     a zero that changes what you do — red, bold, never quiet
   All three are inline, for a cell or a sentence; `ax-empty` is the section-level
   statement. */
.ax-unknown {
  font-style: italic;
  color: var(--muted, var(--subdued));
}
.ax-none {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-zero {
  font-weight: 700;
  color: var(--red);
  font-variant-numeric: tabular-nums;
}

/* ══ Screen-reader only ═════════════════════════════════════════════════════
   The visually-hidden utility, once. It existed as three copies (two in one
   app, one inlined in shell.css) before the sweep. `.is-focusable` for a skip
   link: hidden until it takes focus, then a normal element. */
.ax-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.ax-sr.is-focusable:focus-visible {
  position: static;
  width: auto;
  height: auto;
  margin: 0;
  overflow: visible;
  clip: auto;
  white-space: normal;
}

/* ══ Record — evidence, verbatim ═══════════════════════════════════════════
   A response body, a log line, a provenance trail. Mono throughout because it
   is a record and not prose, and inset because it always sits inside the thing
   it is evidence for. */
.ax-record {
  background: var(--space, var(--surface3));
  border: var(--ax-line);
  border-radius: var(--ax-r);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  line-height: 1.55;
  padding: 0.8rem 1rem;
  overflow-x: auto;
}

/* ══ Ledger — a table of measurements ══════════════════════════════════════
   Tabular figures are not optional here. A column of scores with proportional
   digits does not line up, and a number you cannot compare down the column is
   not doing its job. */
.ax-ledger {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--ax-fs-sm);
}
/* ⚠️ VERTICAL PADDING COMES FROM THE DENSITY TOKEN (cbapex#16). `.ax-ledger`
   used fixed cell padding, so the three density buttons in the control strip
   were INERT wherever this component appears — which is every ledger in nine
   apps. `shell-controls.js` has been setting the tokens all along and
   `shell.css` already wires `.data-table` to `--shell-cell-y`; this is the
   shared ledger finally doing the same.

   ⚠️ `--shell-cell-y`, not `--shell-row-y`, which is what #16 proposed. Both
   exist: rows/cards take `--shell-row-y` (0.4 / 0.7 / 1rem), TABLE CELLS take
   `--shell-cell-y` (0.3 / 0.55 / 0.8rem) — a deliberately smaller step, because
   a table has many more of them and the same increment reads as loose. The cell
   token already had two consumers in the kit; using the row token here would
   have made ledgers step twice as hard as `.data-table` beside them.

   Horizontal padding stays fixed on purpose: shell.css's own note says the
   density step is "mostly VERTICAL, because that is what decides how many rows
   land on a phone screen, while horizontal padding barely moves". */
.ax-ledger th {
  text-align: left;
  padding: var(--shell-cell-y, 0.45rem) 0.6rem;
  border-bottom: var(--ax-line);
  font-family: var(--ax-mono);
  font-size: var(--ax-label-size);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent);
  font-weight: 700;
}
.ax-ledger td {
  padding: var(--shell-cell-y, 0.5rem) 0.6rem;
  border-bottom: 1px solid var(--line);
}
.ax-ledger .num,
.ax-ledger td.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
/* ⚠️ A ROW HAS A STATE, AND A FAILED ROW OWES ITS REASON (cbapex#97). Three
   apps drew the same thing three ways — cbvmod's run history (`tr.is-bad` +
   `tr.rh-why`), cbsvi's byte-identical copy of it, cbrural's feed post-mortems
   (`tr.fr-why`): a run that failed is tinted so the eye finds it down a long
   column, and the error prose — three sentences on cbrural's killed rates run,
   not a code — gets a FULL-WIDTH row of its own under the run it belongs to,
   attached (no top border, no top padding) rather than a run in its own right.
   Truncated into a cell with a tooltip it is a field nobody reads, and a
   failed run whose reason is invisible is what the view exists to prevent.

   `tr.is-bad` / `tr.is-warn` / `tr.is-ok` on the ROW; `tr.ax-why` is the
   reason under it — one `<td colspan>` spanning the table, an optional
   `.ax-why-k` mono key ("why") in front. It is NOT `ax-row-more`: that is a
   popover hiding columns that did not fit; this is a sentence that must be
   read without a tap. The tint follows the row above it, so a why-row under a
   warn row reads as a warning. Semantic colours only — never the accent. */
.ax-ledger tr.is-bad td {
  background: color-mix(in srgb, var(--red, #f14668) 9%, transparent);
}
.ax-ledger tr.is-warn td {
  background: color-mix(in srgb, var(--amber, #ffba55) 9%, transparent);
}
.ax-ledger tr.is-ok td {
  background: color-mix(in srgb, var(--green, #3ddc97) 7%, transparent);
}
.ax-ledger tr.ax-why td {
  padding-top: 0;
  border-top: 0;
  color: var(--subdued);
  white-space: normal;
}
.ax-ledger tr.is-bad + tr.ax-why td {
  background: color-mix(in srgb, var(--red, #f14668) 5%, transparent);
}
.ax-ledger tr.is-warn + tr.ax-why td {
  background: color-mix(in srgb, var(--amber, #ffba55) 5%, transparent);
}
.ax-why-k {
  font-family: var(--ax-mono);
  font-size: var(--ax-label-size);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  margin-right: 0.5rem;
}

/* ══ App shell ═════════════════════════════════════════════════════════════
   An Apex app is a standalone product surface, not a tile inside Loom's
   launcher, so it wears its own header rather than the platform's. */
.ax-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  padding: 0.85rem 1.1rem;
  background: var(--surface);
  border-bottom: var(--ax-line);
  box-shadow: var(--ax-lift-sm);
}
.ax-head b {
  font-weight: 800;
  letter-spacing: -0.01em;
}
.ax-head .ax-sub,
.ax-head .ax-ident {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.08em;
  color: var(--subdued);
}

/* ═══════════════════════════════════════════════════════════════════════════
   PART TWO — the lab's widget zoo, as app furniture
   ═══════════════════════════════════════════════════════════════════════════
   Everything above was extracted from cbprospect: the parts an app invents for
   itself. Everything below comes the other way, from
   `cbnominate/site/src/styles/tokens.css` and `cbnominate/lab/src/styles/
   lab-components.css` — the design system's own components, which existed only
   inside an Astro site and a Playwright zoo and could not be used by an app.

   ── WHAT CHANGED IN THE PORT, AND WHY ──────────────────────────────────────
   Two substantive changes; everything else is a transcription.

   1. **Colours go through Loom's semantic tokens, not the site's names.**
      The site says `--gold`, `--pink`, `--mint`, `--blue`; those names exist
      only in nominate's palette, so a component written against them renders
      unstyled under any other theme and there is nothing in CSS to warn you.
      Each maps onto the token that carries the same MEANING — gold is the
      highlight (`--amber`), pink is refusal (`--red`), mint is success
      (`--green`), blue is provenance (`--info`). nominate.css already maps all
      four, so the rendering is identical and the components now survive a
      theme switch.

   2. **The coverage bar was folded into `ax-meter` rather than copied.**
      The lab's `.bar` and this file's `.ax-meter` are the same component drawn
      twice — a bounded rule with a proportional fill. The one thing `.bar` had
      that the meter did not is the hatched `partial` fill, so that became
      `.ax-meter.is-partial` and `.bar` did not come across. A design system
      that ships two spellings of one component has already lost the argument
      it exists to win.

   ── WHY A COMPONENT IS HERE AT ALL ─────────────────────────────────────────
   Everything below is furniture an app needs. What did NOT come across is the
   editorial chrome — the site's hero, starfield, section stack and prose
   rhythm — which belongs to a marketing page and would fight the app shell. */

/* ══ Button ═══════════════════════════════════════════════════════════════
   Two variants, no third. Primary carries the hard shadow and translates on
   :active, so the press is felt and not merely seen — the shadow stays put
   while the button moves into it. A third variant is how a system starts
   meaning nothing, so if you need one, argue for it here rather than adding a
   one-off class next to a call site. */
/* `hidden` must actually hide. The UA's `[hidden] { display: none }` loses to
   the authored `display: inline-flex` below — the attribute looks respected in
   the inspector while the button stays on screen. Third sighting of this trap
   in one day (the tab strip, the invite form, now a sign-out for nobody). */
.ax-btn[hidden] {
  display: none;
}
.ax-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  /* ⚠️ 2.75rem IS 44px AND THAT IS A FLOOR, NOT A STYLE CHOICE. Operator asked
     for sleeker and smaller (2026-09-04); this is as small as a button gets
     while still being a tap target. It is DOWN from 2.9rem, and the padding and
     type came down with it. Anything below this needs a pointer, not a thumb. */
  min-height: 2.75rem;
  padding: 0.5rem 0.9rem;
  /* ⚠️ EXPLICIT, because a bare <button> inherits no colour and iOS Safari
     paints an unstyled one in system blue — "Take a snapshot" (a real <button>)
     came out blue on every iPhone while "Upload a file" (a <label>) inherited
     white and looked right. Found 2026-08-25. The value changed on 2026-09-04
     and the REASON did not: it must be authored, whatever it is. */
  color: var(--paper-foreground);
  /* ⚠️ SOLID — AND THE TRANSPARENT IT REPLACES WAS A BUGFIX WEARING A DESIGN'S
     CLOTHES. With no authored fill a <button> paints the UA's ButtonFace, which
     under `color-scheme: dark` is near-black, so every bare .ax-btn was a black
     slab with a white frame on a phone (operator, 2026-08-27: "Refresh"). The
     fix authored `transparent` — correct about the bug, and it quietly turned
     the family's default button into an OUTLINE. Nobody chose that.

     Operator, 2026-09-04: "We had these really cool button style at one point -
     and somehow we shifted to the outline style… I want them sleeker / smaller /
     less boxy / solid." The basis was shipaton.com, and reading its markup
     settles it: BOTH its variants are filled — primary `bg-accent`, secondary
     `bg-paper` — and it has no transparent button at all. `--paper` and
     `--paper-foreground` came across into tokens.css with the rest of that
     language and have been sitting unused. Measured: paper ink 14.49:1 on
     paper, and the button reads 18.17:1 against the garnet ground.

     So the hierarchy is TWO FILLS AND TWO OUTLINES, and the split is by intent
     rather than by rank: primary fills with the accent, default fills with
     paper — those are the things you are meant to press. `ghost` and `danger`
     stay transparent, and danger fills ONLY on hover, which is the existing
     "destructive is quiet until you mean it" gesture and is left exactly as it
     was. control-fill's test pins that a background is DECLARED, never which,
     precisely so this could change without the guard fighting it. */
  background: var(--paper);
  border: 2px solid var(--line);
  border-radius: var(--ax-r);
  font: inherit;
  font-weight: 800;
  font-size: var(--ax-fs-sm);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  transition: 0.15s;
}
/* The reference carries `shadow-[5px_5px_0]` on BOTH its filled variants, and
   that is the half of "really cool" that is not colour: the shadow stays put
   while the button translates INTO it on :active, so a press is felt and not
   merely seen. It used to be primary's alone, which is why every other button
   read as flatter furniture beside it. */
.ax-btn {
  box-shadow: var(--ax-lift-sm);
}
.ax-btn:hover {
  border-color: var(--accent);
}
.ax-btn:active {
  transform: translate(2px, 2px);
}
/* ⚠️ 0.6 IS NOT ENOUGH ON A SATURATED FILL (cbapex#15). The opacity rule was
   never missing — it just does not carry on a filled primary: a garnet or ruby
   `.ax-btn-primary` at 0.6 still reads as an ordinary live button, and a dead
   control with no visible reason is the shape people click three times and then
   report as broken. cbvmod worked around it locally, which made it drift too.

   Only visible by LOOKING: the computed style says 0.6 either way and no gate
   has an opinion, which is why this sat open.

   So the fill is drained as well as dimmed — the accent is mixed most of the way
   into the surface, which keeps the button's shape and text position exactly and
   removes the one signal that says "press me". `color-mix` degrades to the plain
   opacity rule in anything that does not support it, and the border/shadow go
   with the fill so a ghost variant does not keep a live-looking edge. */
.ax-btn[disabled] {
  opacity: 0.6;
  cursor: not-allowed;
  transform: none;
}
/* The filled variant needs the FILL drained, not just dimmed. Placed after
   `.ax-btn-primary` below would be neater, but the cascade wants it late and
   equal-specificity, so it lives with its siblings and uses the same two
   selectors the primary sets. */
.ax-btn-primary[disabled],
.ax-btn.ax-btn-primary[disabled] {
  background: color-mix(in oklab, var(--accent) 22%, var(--surface, #1b1420));
  border-color: color-mix(in oklab, var(--accent) 30%, var(--surface, #1b1420));
  color: var(--subdued, #9aa3a6);
  box-shadow: none;
  /* ⚠️ Opacity back to 1, because the DRAIN is the signal and the two together
     are worse than either. Measured in /skin/preview.html: the drained fill
     lands at oklab lightness 0.31 against the live accent's rgb(224,24,48), so
     "off" is already unmistakable — and keeping 0.6 on top only made the LABEL
     hard to read. A disabled control still has to be readable; it is telling you
     what it would do. */
  opacity: 1;
}
.ax-btn-primary {
  border-color: var(--accent);
  background: var(--accent);
  /* The one place a fill is allowed. It reads against the accent because the
     token is the palette's own ground colour, not a hardcoded #fff — which is
     exactly the bug cbloom#321 files against loom.css's badges. */
  color: var(--n-accent-foreground, var(--bg));
  box-shadow: var(--ax-lift-sm);
}
.ax-btn-primary:hover {
  border-color: var(--accent-hover);
  background: var(--accent-hover);
}
.ax-btn-ghost {
  border-color: var(--line);
  background: transparent;
  color: var(--text);
  /* ⚠️ NO LIFT ON THE TRANSPARENT VARIANTS. The hard shadow says "this is a
     solid object sitting above the page"; under a button you can see through it
     reads as a drop shadow on a frame, which is the clunk the 2026-09-04 pass
     was asked to remove. The reference agrees — its quiet buttons carry no
     shadow at all. `danger` gets it back on :hover, at the moment it fills. */
  box-shadow: none;
}
.ax-btn-ghost:hover {
  border-color: var(--accent-hover);
  color: var(--accent-hover);
}
/* ── Icon button — NOT a third variant of .ax-btn, a different object ────────
   No frame, no label, one glyph, 2.25rem square. It exists because the only
   button was the 2.9rem `.ax-btn`, and an action INSIDE a document row or a
   table cell at that height sets the height of every row on the page — which
   is why one app grew a 2.25rem `hr-pdf-btn` and six call sites repeated its
   reset inline (cbapex#97). The row is the generous target; this is the glyph.

   ⚠️ `aria-label` is REQUIRED — there is no text. files.test.ts checks every
   one on a kit page. An inline `<svg>` using `currentColor` recolours on hover;
   a cbcdn `<img>` icon cannot — and it does NOT "get the tint", which this
   comment used to claim: `color` does not reach an <img>. The cbcdn icons
   (eye, open-link, trash — the only three the family puts in this button)
   are drawn with `stroke="#000000"`, so on the dark ground they were black
   on near-black, measured on cbvipulse /app/hr (cbapex#145, operator: "too
   dark to actually see them"). The <img> is INVERTED to ink and softened to
   .78, hover/focus to 1. `--ax-icon-invert: 0` in an app's brand.css turns
   it off for a light ground. Known limit: `.is-danger` hover cannot tint an
   <img> red — an inline <svg> can.
   `.is-danger` for remove/void: the hover tints red instead of accent. */
.ax-iconbtn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  flex: 0 0 auto;
  padding: 0;
  background: none;
  border: 0;
  border-radius: var(--ax-r-tight);
  color: var(--subdued);
  cursor: pointer;
  text-decoration: none;
}
.ax-iconbtn[hidden] {
  display: none;
}
.ax-iconbtn:hover {
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.ax-iconbtn.is-danger:hover {
  color: var(--red);
  background: color-mix(in srgb, var(--red) 12%, transparent);
}
.ax-iconbtn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.ax-iconbtn[disabled] {
  opacity: 0.6;
  cursor: not-allowed;
}
.ax-iconbtn svg,
.ax-iconbtn img {
  width: 1.05rem;
  height: 1.05rem;
  pointer-events: none;
}
.ax-iconbtn img {
  filter: invert(var(--ax-icon-invert, 1));
  opacity: 0.78;
}
.ax-iconbtn:hover img,
.ax-iconbtn:focus-visible img {
  opacity: 1;
}

/* ══ THE WORKLIST ACTION SLOT (cbapex#91, #70) ═════════════════════════════
 * A worklist is `.ax-stat` + `.ax-scroller` + `.data-table` — all of which
 * already exist and the table CELL already tracks `--shell-cell-y` (see the
 * `.data-table` block above). The one thing missing was the ACTION inside the
 * row: `.ax-btn` is a fixed 2.9rem (~46px), so a per-row remind/send/merge
 * button made every row a button row and the worklist was unusable at 390 —
 * exactly the phone-on-a-property viewport HR is used on. */

/* ⚠️ CONTEXTUAL, NOT A VARIANT (cbapex#91). A 24px button is a bug loose on a
 * page and a deliberate choice inside a dense table, and the only way to keep
 * those distinct is to make it a property of the CONTAINER — so a button in a
 * data table tracks the table's density; the same button anywhere else stays
 * the full 2.9rem. No standalone `.ax-btn-compact` exists to sprinkle — the
 * zoo advertised one for a day (2026-08-28 sweep) and nothing defined it. The
 * compact CONTAINERS are: `.data-table`, `.hero-meta`, `.ax-sect-head`,
 * `.ax-notice-act`, `.ax-person-foot`.
 *
 * The floor is WCAG 2.5.8 AA (24px), NOT AAA (44px): a six-column operational
 * worklist is the "sufficiently dense" presentation the guideline carves out,
 * and forcing 44px there defeats the density scale (#16). The step is VERTICAL
 * only — the horizontal padding stays the generous `.ax-btn` default, so the
 * tap area is wider than the glyph even when the row is tight. */
.data-table .ax-btn {
  min-height: 1.5rem; /* 24px — the AA floor for a dense worklist action */
  padding-block: var(--shell-cell-y, 0.4rem);
  font-size: var(--ax-fs-sm);
}

/* ⚠️ DESTRUCTIVE IS AN OUTLINE, NOT A RED FILL (cbapex#70). The obvious red
 * fill is WRONG in cbvmod, whose ACCENT is red — a red-filled destructive and
 * the accent-filled primary would be the same button. So destructive differs
 * by FORM (outline vs the primary's fill), which is a signal that survives when
 * `--red` and `--accent` are the same hue — "colour is never the only signal".
 * It fills red only on hover: the commit gesture, when you mean it. */
.ax-btn-danger {
  border-color: var(--red);
  background: transparent;
  color: var(--red);
  box-shadow: none;
}
/* ⚠️ `:not([disabled])`: a disabled destructive button must not FILL red under
   the cursor. The primary variant is safe because its drained-disabled rule
   out-ranks its hover; danger has no such rule, and Flux's Mirror page showed a
   disabled "Promote" lighting up as if it would act (2026-10-11). */
.ax-btn-danger:hover:not([disabled]) {
  border-color: var(--red);
  background: var(--red);
  color: var(--n-accent-foreground, var(--bg));
  box-shadow: var(--ax-lift-sm);
}

/* ── The multistate button (operator, 2026-09-07) ──────────────────────────
   "We should have a zoo element for a multistate button that shows status and
   changes state as needed — button just greyed out then back to yellow." The
   sweep button went disabled on the tap and came back live when the request
   failed, and the only word about WHY was a toast that had gone by the time
   anyone looked. The button that starts a thing is where its state belongs:
   the same box, one of five words on it.

     (none)  idle — the label says what a tap does
     busy    the request is in flight — disabled, aria-busy, "Starting…"
     live    the thing is running and the button reports it — "Crawling · 3 of 14"
     done    it finished; the label says so, and a tap starts it again
     bad     it failed; the label IS the reason, and a tap retries

   shell.js's `axBtnState(btn, state, label)` sets it and keeps the idle label
   to come back to. No spinner: the busy portal is the family's one loading
   state, so a state here is a WORD, and live's dot is ax-tag's. The primary's
   drain rule is `.ax-btn.ax-btn-primary[disabled]` (0,3,0) and busy keeps it —
   a request in flight looks drained on purpose — but live must not, so its
   primary selector is 0,4,0 and sits after. */
.ax-btn[data-state="busy"] {
  cursor: progress;
}
.ax-btn[data-state="live"],
.ax-btn.ax-btn-primary[data-state="live"][disabled] {
  border-color: var(--accent);
  background: var(--surface, transparent);
  color: var(--accent);
  box-shadow: none;
  opacity: 1;
  cursor: progress;
}
.ax-btn[data-state="live"]::before {
  content: "●";
  color: var(--accent);
  animation: ax-btn-live 1.6s ease-in-out infinite;
}
.ax-btn[data-state="done"],
.ax-btn.ax-btn-primary[data-state="done"] {
  border-color: var(--green);
  background: var(--surface, transparent);
  color: var(--green);
  box-shadow: none;
}
.ax-btn[data-state="done"]::before {
  content: "✓";
}
.ax-btn[data-state="bad"],
.ax-btn.ax-btn-primary[data-state="bad"] {
  border-color: var(--red);
  background: var(--surface, transparent);
  color: var(--red);
  box-shadow: none;
  /* The reason can be a sentence: it wraps inside the button rather than
     widening the row — the .fb-who trap, one screen up. */
  white-space: normal;
  text-align: left;
  overflow-wrap: anywhere;
}
.ax-btn[data-state="bad"]::before {
  content: "!";
  font-weight: 800;
}
@keyframes ax-btn-live {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.3;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ax-btn[data-state="live"]::before {
    animation: none;
  }
}

/* A table INSIDE a kit card wore two frames: `.ax-stat`/`.ax-sect` draw the
 * card border and loom's `.data-table` draws its own border + radius + surface
 * — measured on the HR queue (2026-08-27) as "2px solid" inside "2px solid"
 * with 8.8px of padding between. The card owns the frame; the table keeps only
 * its row separators. Lived in one app's CSS until the sweep; every worklist
 * the kit's own docs prescribe walks into it. No `display` authored. */
.ax-stat .data-table,
.ax-sect .data-table {
  border: none;
  border-radius: 0;
  background: transparent;
}
/* The ACTION GROUP — two or three buttons that belong to one row or one cell,
 * wrapping rather than overflowing. In a table cell it takes the cell's own
 * vertical step so the row stays aligned with its neighbours. Was
 * `hr-row-actions` in one app; every worklist with more than one action has it. */
.ax-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
}
.data-table .ax-actions {
  padding-block: var(--shell-cell-y, 0.3rem);
}

/* ══ WORKLIST LAYOUT AT 390 (cbapex#89 deliverable 2) ══════════════════════
 * The action slot made the buttons right; this makes the TABLE right on a
 * phone, where a multi-column worklist otherwise pushes its action column —
 * the point of the row — off-screen behind `.ax-scroller`.
 *
 * ⚠️ OPT-IN per worklist, NEVER a blanket breakpoint on every `.data-table`.
 * cbvipulse-37 measured why: OT Trends is a numeric grid where comparison DOWN
 * a column is the feature, and Labor alerts is already a 2-col list — carding
 * either wrecks it, silently, because it still renders. The default
 * `.data-table` (no worklist class) stays today's scroll, so a careless adopter
 * gets a working table, not a broken grid. Two explicit modes below; pick per
 * worklist by ADDING a class to the table: `<table class="data-table
 * ax-worklist-cards">` or `ax-worklist-sticky`.
 *
 * ⚠️ THE HOOK IS AN ax- CLASS, NOT `.data-table[data-layout=…]`, and that is
 * load-bearing (components-hidden.test.ts). `.data-table` is LOOM's class, so a
 * layout rule scoped to it sits OUTSIDE the ax- `[hidden]` guard's reach — and
 * `.data-table[data-layout="cards"] tr` is (0,2,1), one above the guard's
 * (0,2,0), so a `hidden` row would RENDER. A single `.ax-worklist-cards` class
 * is (0,1,0): its descendant rules land at (0,1,1), BELOW the guard, so
 * `hidden` wins for free and the rules are in the namespace the guard covers.
 * A class+attribute hook (0,2,0) would tie the guard and still need a per-rule
 * `[hidden]` neighbour — the class is what makes that unnecessary.
 *
 * The transform reads the markup, never re-parses a cell string (the wrong
 * direction): each cell carries its header as `data-label`, the standard
 * responsive-table contract, and the action cell is marked `data-action`. */

/* MODE 1 — CARDS. Below 40rem each row becomes a card; cells show their label,
 * the action goes full-width, and a `col-lo` column (desktop detail) drops. For
 * action-led lists: New Onboarding, Informal Departures. */
@media (max-width: 40rem) {
  .ax-worklist-cards,
  .ax-worklist-cards tbody,
  .ax-worklist-cards tr,
  .ax-worklist-cards td {
    display: block;
  }
  .ax-worklist-cards thead {
    display: none; /* labels move into the cells via data-label */
  }
  /* ⚠️ A CARD IS FOUR THINGS TOGETHER: surface, line, radius, lift. This
     rule had the middle two, so a row that became a card on a phone was a
     flat outline beside every `ax-card` on the same screen (operator,
     2026-09-29, comparing Save VI's Signups with its Movers). The recipe is
     `ax-card`'s; `card-recipe.test.ts` holds every card to it. */
  .ax-worklist-cards tr {
    background: var(--surface);
    border: var(--ax-line);
    border-radius: var(--ax-r);
    box-shadow: var(--ax-lift);
    padding: var(--shell-cell-y, 0.5rem) 0.75rem;
    margin-bottom: 0.75rem;
  }
  /* ⚠️ AND THE TABLE ITSELF STANDS DOWN. Loom's `.data-table` draws a frame
     on the TABLE: a border, a fill, a radius and `overflow: hidden`. Carded,
     that is a box round the whole list with every card 2px inside it, and a
     clip on each card's shadow. cbvmod's session measured it at 390 on its
     governor model the hour the card recipe landed: table x 31.6 to 358.4,
     first card 33.6 to 356.4. `table.` so it outranks Loom's own class. */
  table.ax-worklist-cards {
    border: 0;
    border-radius: 0;
    background: none;
    box-shadow: none;
    overflow: visible;
  }
  /* A scroller clips what leaves its box, and a shadow leaves it. Below the
     breakpoint the cards stack and nothing scrolls, so it lets them show. */
  .ax-scroller:has(> .ax-worklist-cards) {
    overflow: visible;
  }
  .ax-worklist-cards td {
    display: flex;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 0.25rem 1rem;
    border: none;
    padding: 0.2rem 0;
    /* ⚠️ A CARD CELL IS A FLEX ROW, AND A FLEX ITEM'S FLOOR IS ITS LONGEST
       WORD. A hostname, a URL, a sha or an email has no break opportunity, so
       the item could not shrink and the PAGE scrolled sideways — Forge's
       counter by 21px at 390 (connector.campaignbrain.dev), its recorder by
       217px (an error carrying a vault URL). Measured 2026-09-09. `min-width:
       0` on the children lets the row shrink; `overflow-wrap: anywhere` lets
       the token break rather than push. */
    min-width: 0;
    overflow-wrap: anywhere;
  }
  .ax-worklist-cards td > * {
    min-width: 0;
  }
  /* A carded table that ALSO sits in an .ax-scroller — right above 40rem,
     where it is a table you read and must scroll — keeps the scroller's
     `min-width: max-content` down here too, so the cards became a 1033px
     strip in a 327px box. Below the breakpoint the cards stack; the scroller
     is inert. */
  .ax-scroller .ax-ledger.ax-worklist-cards {
    min-width: 0; /* (0,3,0): it has to outrank `.ax-scroller .ax-ledger` below, which comes LATER in this file */
  }
  .ax-worklist-cards td[data-label]::before {
    content: attr(data-label);
    color: var(--muted);
    font-weight: 600;
  }
  .ax-worklist-cards td[data-action] {
    margin-top: 0.4rem;
  }
  .ax-worklist-cards td[data-action] .ax-btn {
    flex: 1; /* full-width tap target where a thumb lands */
  }
  .ax-worklist-cards td.col-lo,
  .ax-worklist-cards th.col-lo {
    display: none; /* low-priority column — desktop detail, dropped on the phone */
  }
}

/* ══ Detail — a row's detail, opened beneath it (cbapex#168) ═════════════
   One tap behind a row: the day's transactions, a record's other fields.
   Promoted from cbadmin, which carried the rule twice under two names and
   the toggler three times. `<button data-detail="key">` in the row opens it;
   the shell inserts `tr.ax-detail` and removes it on close.

   ⚠️ NOT A TABLE INSIDE. A table in a carded table inherits the carded
   rules: measured by cbadmin at 390, its rows became bordered cards, its
   head vanished, and the box scrolled sideways (501px in 358). Use `ax-list`
   or `ax-kv`.
   ⚠️ IT IS NOT `ax-why`. That is a sentence that must be read without a tap;
   this is what a tap asks for. Both travel with the row above and neither is
   counted by the pager.
   ⚠️ IT IS NOT `ax-row-more`. That is a popover for columns that did not fit. */
tr.ax-detail > td {
  background: color-mix(in srgb, var(--surface) 70%, transparent);
  padding-top: var(--shell-box-y, 0.9rem);
  padding-bottom: var(--shell-box-y, 0.9rem);
  white-space: normal;
  overflow-wrap: anywhere;
}
tr.ax-detail > td > :first-child {
  margin-top: 0;
}
tr.ax-detail > td > :last-child {
  margin-bottom: 0;
}
@media (max-width: 40rem) {
  /* In a carded table every cell is a flex row. The detail's pieces stack,
     and the label the cards paint from `data-label` gets its own line. */
  /* ⚠️ ONE CLASS ON THE CELL, put there by the shell when the table is
     carded. Scoping by the table instead costs a second class, which ties
     the `[hidden]` guard. `td.` is what lets it beat `.ax-worklist-cards td`
     above it, by source order at equal weight. */
  td.ax-detail-card {
    display: block;
  }
  /* No `[data-label]` here: with it the rule weighs (0,2,1) and ties the
     guard. Without a label there is no content, so no box either. */
  td.ax-detail-card::before {
    display: block;
    margin-bottom: 0.4rem;
  }
}

/* MODE 1b — STACKED AT EVERY WIDTH (`ax-stackrows`, promoted from cbrural,
 * cbapex#168). For rows that are a FORM, not a table: five controls a row is
 * not something a `<table>` is for at any width — cbrural's rate triage lost
 * Ad type and Gross off the right edge at 1440, beside a sticky document. Each
 * row is a block, each cell a labelled line (`data-label`), at every width.
 * The head is CLIPPED, not `display: none`: a screen reader still reads it.
 * ⚠️ cbrural carried this rule in its own sheet under the kit's prefix — an
 * app rule wearing `ax-` is a component nobody else can find, and one a kit
 * rule of the same name would silently fight. */
.ax-stackrows,
.ax-stackrows tbody,
.ax-stackrows tr,
.ax-stackrows td {
  display: block;
  width: auto;
}
.ax-stackrows thead {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}
.ax-stackrows tr {
  border: var(--ax-line);
  border-radius: var(--ax-r);
  padding: var(--shell-cell-y, 0.5rem) 0.75rem;
  margin-bottom: 0.6rem;
  background: var(--surface);
}
.ax-stackrows td {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.25rem 0.8rem;
  border: 0;
  padding: var(--shell-cell-y, 0.3rem) 0;
  text-align: left;
  min-width: 0;
  overflow-wrap: anywhere;
}
.ax-stackrows td > * {
  min-width: 0;
}
.ax-stackrows td[data-label]::before {
  content: attr(data-label);
  flex: 0 0 auto;
  color: var(--muted);
  font-size: var(--ax-fs-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.ax-scroller .ax-ledger.ax-stackrows {
  min-width: 0;
}

/* MODE 2 — STICKY ACTION. For a worklist that must STAY a table because the row
 * is a side-by-side comparison (Legacy Reconciliation: "are these two the same
 * person?" — carding destroys the compare) but whose action must still be
 * reachable at 390. The last column pins to the right edge, in view while the
 * comparison columns scroll under it. Opt-in, because it only helps when the
 * last column IS an action. */
.ax-worklist-sticky td:last-child,
.ax-worklist-sticky th:last-child {
  position: sticky;
  right: 0;
  background: var(--surface);
  box-shadow: -6px 0 8px -6px rgb(0 0 0 / 40%);
}
/* ⚠️ THE SAME OPAQUE RULE AS `ax-pinned`, FOUND BY RENDERING THE TWO
   TOGETHER (2026-09-28). In a ledger box the header fill is 75% and a row's
   state tint is 9% over transparent, and both out-weigh the fill above: a
   column header read "ACT10" through the pinned one and a number showed
   through the button in a failed row. And a cell that SPANS the table is a
   last child too; it is the reason or the detail, not an action. */
/* ⚠️ A PINNED CELL STICKS TO ITS NEAREST SCROLLING ANCESTOR, and Loom's
   `.data-table` sets `overflow: hidden`, which makes the TABLE that
   ancestor. The table never scrolls, so nothing ever stuck: measured on
   cbvipulse's own sheets, the action sat 218px off the right edge at rest,
   with `position: sticky` computed and correct. Both pin modes need the
   table to let the scroller be the scroller. */
.ax-worklist-sticky,
.ax-pinned {
  overflow: visible;
}
.ax-worklist-sticky td[colspan]:last-child {
  position: static;
  box-shadow: none;
}
.ax-ledger-box .ax-worklist-sticky th:last-child {
  background: var(--surface);
}
.ax-worklist-sticky tr.is-bad > td:last-child:not([colspan]) {
  background: color-mix(in srgb, var(--red, #f14668) 9%, var(--surface));
}
.ax-worklist-sticky tr.is-warn > td:last-child:not([colspan]) {
  background: color-mix(in srgb, var(--amber, #ffba55) 9%, var(--surface));
}
.ax-worklist-sticky tr.is-ok > td:last-child:not([colspan]) {
  background: color-mix(in srgb, var(--green, #3ddc97) 7%, var(--surface));
}

/* ══ WORKLIST PAGER (cbapex#89) — the control `shellPaginate()` appends ══════
 * A "Show N more" at the BOTTOM (the count headline stays at the top). The note
 * SURFACES what paging hides: a data-critical row below the fold turns the note
 * red — never a silent bury. Absent entirely below the threshold (the JS does
 * not create it), so this styles a control that only exists when it means
 * something. */
.ax-pager {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 0.6rem;
  flex-wrap: wrap;
}
.ax-pager[hidden] {
  display: none;
}
/* A paged-out row must actually leave. `hidden` is the UA's `display: none`,
   which any authored display beats — and the default pager (cbapex#168) reaches
   app-local cards whose own sheet says `display: grid`. The marker is the
   pager's own, so this outranks nothing a page hides for itself. */
[data-paged-out] {
  display: none !important;
}
.ax-pager-note {
  font-size: var(--ax-fs-xs);
  color: var(--muted);
}
.ax-pager-note.is-critical {
  color: var(--red);
  font-weight: 700;
}

/* ══ ROW-DETAIL DISCLOSURE (cbapex#89 increment 4) ═════════════════════════
 * Secondary and DESTRUCTIVE actions one deliberate tap behind a worklist row's
 * safe primary. A sticky/carded row has room for ONE comfortable target at 390,
 * so a row with more than one action — Legacy Reconciliation's merge · mark
 * manual · TERMINATE — must not pin all three side by side. The pinned/primary
 * slot carries the SAFE common action; the rest live here.
 *
 * ⚠️ THIS IS A SAFETY CONTROL, and that is why it is the kit's, not each app's
 * markup: a per-app disclosure drifts, and the day an app pins its destructive
 * action because it was easier, the worklist looks normal right up until someone
 * mistaps. Same reasoning as signOut and the [hidden] guard — invisible failure,
 * asymmetric cost. `ax-btn-danger` BELONGS in this opened panel, never in the
 * pinned column.
 *
 * ⚠️ NATIVE `popover`, no JS: `<button class="ax-row-more" popovertarget="x">2
 * more</button>` + `<div id="x" popover class="ax-row-more-panel">…</div>`. The
 * popover renders in the TOP LAYER, so the panel is NOT clipped by
 * `.ax-scroller`'s `overflow` — a disclosure inside a scrolling table that its
 * own container clips is the failure this avoids. `popovertarget`/`popover` are
 * HTML; nothing here needs a script or the CSP widened.
 *
 * ⚠️ THE TRIGGER CARRIES THE COUNT ("2 more"), never a bare ellipsis. A safety
 * affordance has to say there ARE extras before the tap; `⋯` reads as overflow
 * chrome and gets ignored, which is the opposite of what this wants. */
.ax-row-more {
  min-height: 1.5rem;
  padding-block: var(--shell-cell-y, 0.4rem);
  font-size: var(--ax-fs-sm);
  /* it is an .ax-btn.ax-btn-ghost in markup; these match the worklist action
     slot so the trigger sits at the same weight as the primary beside it. */
}
.ax-row-more-panel {
  margin: auto; /* a centred action sheet — the popover default, dimmed backdrop */
  min-width: 12rem;
  max-width: min(90vw, 22rem);
  padding: var(--shell-box-y, 0.9rem) var(--shell-box-x, 1rem);
  background: var(--surface);
  border: var(--ax-line);
  border-radius: var(--ax-r);
  box-shadow: var(--ax-lift);
  /* ⚠️ NO `display` here. The popover UA sets `display: none` when CLOSED, and
     an author `display` overrides it — measured: `display: flex` left the closed
     panel rendering inline in the cell with Terminate visible ALWAYS, which
     defeats the disclosure. The panel keeps the UA's open/close display; the
     actions stack by width + margin instead of flex-on-the-panel. */
}
.ax-row-more-panel .ax-btn {
  width: 100%; /* full-width — one target at a time, danger alone at the end */
}
.ax-row-more-panel .ax-btn + .ax-btn {
  margin-top: 0.5rem; /* the gap flex would have given, without touching display */
}
/* ── The Card popover, same treatment (operator, 2026-09-07) ──────────────
   The sheet wears the busy portal's glass (the same tint and 3px as
   `.ax-dialog::backdrop` and `.shell-busy`) instead of a flat 45% black, the
   document does not scroll while it is open (`:has` — no script for a native
   control), and shell.js gives it REGIONS on open: the row it is for, then
   its actions, and focus on the first of them. A page can author the heading
   itself with `.ax-row-more-h`; the script adds one only when it is absent. */
.ax-row-more-panel::backdrop {
  background: color-mix(in srgb, var(--bg, #171326) 38%, transparent);
  -webkit-backdrop-filter: blur(3px);
  backdrop-filter: blur(3px);
}
html:has(.ax-row-more-panel:popover-open),
html:has(.ax-row-more-panel:popover-open) body {
  overflow: hidden;
  overscroll-behavior: none;
}
.ax-row-more-h {
  margin: 0 0 0.6rem;
  font-family: var(--ax-mono, ui-monospace, monospace);
  font-size: var(--ax-fs-micro);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-row-more-h b {
  display: block;
  margin-top: 0.2rem;
  font-family: var(--font-body);
  font-size: var(--ax-fs-md);
  letter-spacing: 0;
  text-transform: none;
  color: var(--text);
}


/* ══ Field — a labelled input ══════════════════════════════════════════════
   The design system had no input treatment at all: the site is editorial and
   never asks for anything, so the first form in the language had to invent one
   (the lab's login screen). This is that treatment, promoted.

   The rule it establishes: a form field is the SAME MATERIAL as a card — 2px
   line, same radius — but its shadow is INSET rather than lifted. A raised
   input reads as a button and gets clicked instead of typed in. */
.ax-field {
  display: grid;
  gap: 0.4rem;
  min-width: 0;
}
.ax-field-top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
}
/* `:not(.ax-btn)`: a <label class="ax-btn"> that fronts a hidden file input is
   a BUTTON that happens to be a label (the stash pattern); the caption rule
   turned "Attach a letter" into a mono uppercase caption inside a button. */
.ax-field label:not(.ax-btn),
.ax-field-l {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  font-weight: 700;
  letter-spacing: var(--ax-label-track);
  text-transform: uppercase;
  color: var(--subdued);
}
/* ⚠️ THE STANDALONE CLASSES ARE NOT A SECOND COPY — THEY SHARE THIS BLOCK.
   Until 2026-08-24 a control was styled ONLY as a descendant of `.ax-field`,
   so every control that was not wrapped in one fell back to the browser's
   default: no token border, no sink, no radius, no focus colour. That is not a
   rare shape — the chat peer-search, the invite rows, cbvmod's segment builder
   and a bare `<select>` in prospect all rendered as raw UA widgets on a themed
   page. Each page that noticed invented its own name for the fix (`ctrl-input`,
   `ctrl-select`, `wz-field`, `wz-op`, `wz-unit`, `wz-val`), which is five
   mirrors of one component.

   `.ax-input` / `.ax-select` / `.ax-textarea` are for a control with no field
   furniture around it — no label row, no aux link. Use `.ax-field` when it has
   a label; use these when the placeholder or an `aria-label` IS the label.
   Both share every declaration below, so they cannot drift apart.

   ⚠️ AND `select` WAS IN THE BASE RULE BUT IN NONE OF THE STATE RULES. It got a
   border and then hovered, focused and invalidated like nothing else on the
   page — the give-away that this block was written for text inputs and grew a
   `select` later. Every state below names it now. */
.ax-field input,
.ax-field select,
.ax-field textarea,
.ax-input,
.ax-select,
.ax-textarea {
  font: inherit;
  /* ⚠️ Tightened 2026-09-19 (operator, on a phone: "dropdown spacing / font is
     large"). Was 0.95rem in a 3rem box with 0.7rem padding — 49px tall, and on
     a wide face like Poppins the value text out-shouted every label beside it.
     2.75rem (44px) is the touch floor Apple sets and stays above WCAG 2.5.8's
     24px target, so the tap is not what paid for this. iOS zooms a focused
     control under 16px; it already did at 15.2px, so this moves nothing there. */
  font-size: var(--ax-fs-sm);
  color: var(--text);
  min-height: 2.75rem;
  padding: 0.5rem 0.75rem;
  /* ⚠️ Stated here, not inherited. A control with padding is only sized
     predictably under border-box, and the only global reset supplying it is
     loom.css's line 7 — a VENDORED file. These rules must hold on a page that
     does not load Loom, so the component owns its own box model. */
  box-sizing: border-box;
  border: var(--ax-line);
  border-radius: var(--ax-r);
  background: var(--space, var(--surface3));
  box-shadow: var(--ax-sink);
  transition: 0.15s;
}
/* ⚠️ WIDTH IS THE ONE THING THE TWO FORMS DO NOT SHARE, AND IT IS WHY THEY ARE
   TWO FORMS. A control inside `.ax-field` fills the field — that is what the
   wrapper is for. A STANDALONE control does not: it appears inline in a flex
   row beside a sentence ("Treat records as the same person when [ ▾ ]"), in a
   settings row next to a switch, in a table cell. Giving those `width: 100%`
   makes the control eat the row and wrap its own label onto the next line.
   So the standalone classes size to their content, and a call site that wants
   a full-width one says so — one line, where the layout decision belongs. */
.ax-field input,
.ax-field select,
.ax-field textarea {
  width: 100%;
}
/* The arrow stays the browser's. A CSS-drawn one needs either a wrapper element
   at every call site or a `data:` background — and `data:` URLs are forbidden
   fleet-wide by the CSP, deliberately. Inheriting `color` keeps the native
   arrow legible in both themes, which is the part that actually matters. */
.ax-field select,
.ax-select {
  appearance: auto;
  padding-right: 0.5rem;
}
.ax-field textarea,
.ax-textarea {
  min-height: 6rem;
  line-height: 1.5;
}
.ax-field input::placeholder,
.ax-field textarea::placeholder,
.ax-input::placeholder,
.ax-textarea::placeholder {
  color: color-mix(in srgb, var(--subdued) 55%, transparent);
}
.ax-field input:hover,
.ax-field select:hover,
.ax-field textarea:hover,
.ax-input:hover,
.ax-select:hover,
.ax-textarea:hover {
  border-color: color-mix(in srgb, var(--accent-hover) 55%, var(--line));
}
/* ⚠️ `outline: none` is only safe because the border IS the focus indicator and
   it changes to the accent. Do not copy this pair apart. */
.ax-field input:focus,
.ax-field select:focus,
.ax-field textarea:focus,
.ax-input:focus,
.ax-select:focus,
.ax-textarea:focus {
  border-color: var(--accent);
  outline: none;
}
/* Invalid is a border colour AND the field's own error line below it — colour
   alone is not a state anywhere else in this system and is not one here. */
.ax-field input[aria-invalid="true"],
.ax-field select[aria-invalid="true"],
.ax-field textarea[aria-invalid="true"],
.ax-input[aria-invalid="true"],
.ax-select[aria-invalid="true"],
.ax-textarea[aria-invalid="true"] {
  border-color: var(--red);
}
/* A disabled control must say so without relying on opacity over a fill — the
   same reasoning as `.ax-btn-primary[disabled]` further down (cbapex#15). */
.ax-field input[disabled],
.ax-field select[disabled],
.ax-field textarea[disabled],
.ax-input[disabled],
.ax-select[disabled],
.ax-textarea[disabled] {
  color: var(--subdued);
  background: var(--subtle);
  box-shadow: none;
  cursor: not-allowed;
}
.ax-field-aux {
  /* Padded to the 24px hit floor, layout unchanged — audit.mjs's tap-target
     check, first cycle (cbapex#129). */
  padding-block: 0.35rem;
  margin-block: -0.35rem;
  font-size: var(--ax-fs-xs);
  color: var(--amber);
  text-decoration: none;
  font-weight: 700;
}
.ax-field-aux:hover {
  text-decoration: underline;
}

/* ── Attach — a file input behind a real button (cbapex#97) ─────────────────
   A bare `<input type="file">` renders the browser's "Choose File · No file
   chosen" widget, unthemed, on a page where every other control is the kit's.
   The 2026-08-28 sweep found SIX hand-rolled answers to that in this family:
   four in one app — two of them still the bare widget, 80 lines from a comment
   forbidding it — and two in the kit's own stash and profile screens, one
   patching `cursor:pointer` inline on a class that already sets it.

   The anatomy: `.ax-attach` is the row (it wraps at 390); the control is a
   `<label class="ax-btn ax-attach-btn">` WRAPPING the input; the optional slots
   are a category `.ax-select` (content-sized) and a label `.ax-input.ax-attach-grow`
   (takes the slack); `.ax-attach-name` is the readout of what was chosen. The
   button class stands alone too — a lone "Choose a photo" needs no row.

   ⚠️ THE INPUT IS `class="ax-sr"`, NOT `hidden`. All six hand-rolled versions
   used `hidden` (or `display:none`), and a hidden file input cannot take focus:
   the button is mouse-only, and on a keyboard or a switch the control does not
   exist. `.ax-sr` keeps it in the tab order, Space/Enter opens the picker, and
   the ring lands on the LABEL through `:focus-within`, because the focused
   element is 1px and clipped. While an upload is in flight the caller sets
   `input.disabled = true` and the button drains through `:has()` — a <label>
   has no `disabled` of its own, so the state has to be read off the input. */
.ax-attach {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem;
  min-width: 0;
}
.ax-attach[hidden] {
  display: none;
}
.ax-attach > .ax-select {
  flex: 0 0 auto;
}
.ax-attach-grow {
  flex: 1 1 10rem;
  min-width: 0;
}
/* `position: relative` anchors the absolutely-positioned `.ax-sr` input to the
   button, so it cannot land off-page and drag the scroll position when focused. */
.ax-attach-btn {
  position: relative;
}
.ax-attach-btn:focus-within {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.ax-attach-btn:has(input:disabled) {
  opacity: 0.6;
  cursor: not-allowed;
  transform: none;
}
.ax-attach-name {
  flex: 1 1 8rem;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--ax-fs-sm);
  color: var(--subdued);
}

/* ══ Tag — read-only status ════════════════════════════════════════════════
   Distinct from `ax-chip` by INTENT, not by looks: a tag states a fact and
   cannot be clicked; a chip is a token; `ax-filter` below is the pressable
   one. If a tag can be clicked it is the wrong element, and the give-away is
   that it will be a <span> someone attached an onclick to. */
/* ⚠️ A TAG IS A BOX, NOT A RUN OF TEXT (operator, 2026-09-28, from a phone:
   "tags at the top are all smashed together"). It set no `display`, so it was
   an inline box: it broke in the middle of its own words ("P14 · EULALIE R."
   ending one line, "RIVERA" starting the next, each half with an open border),
   and its padding took no room on the line, so two lines of tags overlapped.
   `.ax-chip` beside it has always been inline-block. `max-width` lets a tag
   longer than its row wrap INSIDE its own border rather than push the page. */
.ax-tag {
  display: inline-block;
  max-width: 100%;
  vertical-align: middle;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  padding: 0.4rem 0.65rem;
  border-radius: var(--ax-r-tight);
  border: 2px solid color-mix(in srgb, var(--line) 75%, transparent);
  background: color-mix(in srgb, var(--surface) 75%, transparent);
  color: var(--subdued);
}
.ax-tag[hidden] {
  display: none;
}
/* A RUN OF MARKS IN PLAIN TEXT keeps its distance. The kit's containers space
   their children with `gap`; a run written into a paragraph, a cell or a bare
   div has nothing, and string-rendered markup often has no space between the
   tags at all. `:where()` keeps this at zero specificity, and it names only
   UNCLASSED parents, so it never adds to a flex container's gap. */
:where(p, td, li, dd, div:not([class])) > :where(.ax-tag, .ax-chip, .ax-state) + :where(.ax-tag, .ax-chip, .ax-state) {
  margin-left: 0.4rem;
}
:where(p, td, li, dd, div:not([class])) > :where(.ax-tag) {
  margin-block: 0.15rem;
}
.ax-tag.is-live {
  border-color: var(--accent);
  color: var(--amber);
}
.ax-tag.is-live::before {
  content: "●";
  margin-right: 0.4rem;
  color: var(--accent);
}
/* The three states, on the SAME scale as ax-chip and ax-state: ok green, warn
   amber, bad red. `is-warn` used to be red here and nowhere else in the
   language — cbrural read it as amber and overrode it, and the kit's own
   invites/profile pages wrote `is-warn` where they meant a refusal (Expired,
   Not sent, denied); those say `is-bad` now. `is-ok` had been used by the kit's
   stash and profile pages for a week and defined only in cbrural. */
.ax-tag.is-ok {
  border-color: var(--green);
  color: var(--green);
}
.ax-tag.is-warn {
  border-color: var(--amber);
  color: var(--amber);
}
.ax-tag.is-bad {
  border-color: var(--red);
  color: var(--red);
}

/* ══ Filter chip — the pressable one ═══════════════════════════════════════
   A real <button aria-pressed>, and the pressed state changes BOTH the fill
   and the text colour. Pressed-as-colour-only is unreadable to anyone who
   cannot separate the hues, and `aria-pressed` is what makes it a control
   rather than a div that happens to respond. */
.ax-filters {
  display: flex;
  flex-wrap: wrap;
  /* ⚠️ ONE AXIS, NOT `gap` (cbapex#85). The column gap is right; the row gap
     only exists once the strip WRAPS — the charter's 390px is where that
     happens — and at 0.45rem two 31px pill rows read as one dense block.
     0.7rem is the first value where two rows read as two rows (measured at
     390 against 0.45/0.55/0.7). Raising the shorthand instead would widen the
     horizontal rhythm on every non-wrapping strip in the family. */
  column-gap: 0.45rem;
  row-gap: 0.7rem;
}
/* ⚠️ CHIP STRIPS WRAP ONE LEVEL DOWN. `loomPills()` returns its own container,
   so `.ax-filters` sees ONE child and never wraps — the chips wrap inside
   `.loom-pills`, whose vendored rule sets the `gap` SHORTHAND (8px, both
   axes). This descendant selector out-specifies loom's single class; the
   column axis stays loom's. ⚠️ An inline `style="gap: …"` at a call site
   still beats both — it is the shorthand, so it silently opts that strip out
   of this fix (measured on cbvipulse hr.html:3701). */
.ax-filters .loom-pills {
  row-gap: 0.7rem;
}
.ax-filter {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  padding: 0.45rem 0.7rem;
  /* The 32px floor a pressable thing gets on a fine pointer: the chip's own
     paint measured 31.4px (Flux's render probe, 2026-10-12), under by the
     width of a line. Touch raises it further below. min-height only, so the
     [hidden] guard still wins (components-hidden.test.ts). */
  min-height: 2rem;
  /* The one pill in the system, kept deliberately: a filter chip is a pill in
     both systems and the shape is what distinguishes it from a tag at a
     glance. Everything else stays at 3/5/7px. */
  border-radius: 999px;
  border: 2px solid color-mix(in srgb, var(--line) 70%, transparent);
  background: transparent;
  color: var(--subdued);
  cursor: pointer;
  transition: 0.15s;
}
.ax-filter:hover {
  border-color: var(--accent-hover);
  color: var(--accent-hover);
}
.ax-filter[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--text);
  background: color-mix(in srgb, var(--accent) 16%, transparent);
}

/* ══ Source chip and source list — where a number came from ════════════════
   Every number this product shows carries one. That is the whole claim of the
   thing, so the treatment is deliberately unglamorous: mono, bordered, quiet,
   never a coloured pill. A pill reads as a status, and a source is an address.
   The ▤ glyph is the provenance colour and is decorative, so it lives in
   ::before rather than in the DOM where a screen reader would announce it. */
.ax-srcs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.45rem;
}
.ax-src {
  display: inline-flex;
  gap: 0.4rem;
  padding: 0.32rem 0.6rem;
  border-radius: var(--ax-r-tight);
  border: 2px solid color-mix(in srgb, var(--line) 75%, transparent);
  background: var(--space, var(--surface3));
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  color: var(--subdued);
}
.ax-src::before {
  content: "▤";
  color: var(--info, var(--blue));
}

.ax-srclist {
  display: grid;
  gap: 0.6rem;
  font-size: var(--ax-fs-sm);
}
.ax-srclist a {
  /* Padded to the 24px hit floor, layout unchanged — audit.mjs's tap-target
     check, first cycle (cbapex#129). */
  padding-block: 0.3rem;
  margin-block: -0.3rem;
  color: var(--subdued);
  text-decoration: none;
  display: flex;
  gap: 0.5rem;
}
.ax-srclist a:hover {
  color: var(--accent-hover);
}
.ax-srclist a::before {
  content: "▤";
  color: var(--info, var(--blue));
}

/* ══ Notice — the one banner ═══════════════════════════════════════════════
   For certification status, a format change, a caveat that governs the whole
   page. **One per page at most.** A second one means neither gets read, and
   that is a design rule rather than a suggestion — if you find yourself
   needing two, the second is a `ax-error` or it belongs in the row it
   describes. */
.ax-notice {
  border: 2px solid color-mix(in srgb, var(--amber) 55%, var(--line));
  border-radius: var(--ax-r);
  background: color-mix(in srgb, var(--amber) 9%, var(--surface));
  padding: 0.9rem 1.1rem;
  font-size: var(--ax-fs-sm);
  color: var(--subdued);
  line-height: 1.55;
}
.ax-notice b {
  color: var(--amber);
}
/* The notice's own paragraphs and its action row. A notice is prose plus at
   most one control, so this is a rhythm rather than a layout — the row exists
   because a bare <button> after a <p> inherits the paragraph's margin and sits
   too close to the sentence that explains it. */
.ax-notice p {
  margin: 0 0 0.5rem;
}
.ax-notice p:last-child {
  margin-bottom: 0;
}
.ax-notice-act {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  align-items: center;
  margin-top: 0.85rem;
}
.ax-notice-act .ax-btn {
  min-height: 1.75rem;
  padding: 0.25rem 0.7rem;
  font-size: var(--ax-fs-xs);
}

/* ══ Breadcrumb ════════════════════════════════════════════════════════════
   The current level is a <b>, never an <a>. A link to the page you are already
   on is a dead control, and it is the kind that looks fine in review because
   whoever is reviewing already knows where they are. */
.ax-crumb {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-crumb a {
  color: var(--subdued);
  text-decoration: none;
  /* 14px of mono text is the whole link; pad the HIT AREA to the 24px floor
     (WCAG 2.5.8) and take it back with margin so the row does not grow —
     audit.mjs's tap-target check found it on its first run (cbapex#129). */
  padding-block: 0.35rem;
  margin-block: -0.35rem;
}
.ax-crumb a:hover {
  color: var(--accent-hover);
}
.ax-crumb b {
  color: var(--text);
  font-weight: 700;
}

/* ══ Masthead — the page identity block ════════════════════════════════════
   An abbreviation tile, an eyebrow, the name, and the facts about it. The tile
   is the only place in the system where a large glyph is decoration; it earns
   it by being the thing you scan for when you have twelve tabs open. */
.ax-masthead {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 1.25rem 1.5rem;
  align-items: center;
}
/* ⚠️ NAMED FOR ITS SHAPE, AND IT WAS NOT (cbapex#57, renamed 2026-08-26).
   This was `.ax-abbr` — named for the CONTENT its first call site happened to
   put in it (a two-letter state code) rather than for what it IS. It is an
   88×88px display tile: `display: grid`, a fixed square, a border, a lift
   shadow, 2rem type.

   `abbr` is also the name of an HTML element for inline phrasing content, so
   the class promised inline text styling and delivered a block the size of a
   thumbnail. cbconnector reached for it to mark a required field and got a
   bordered box above two inputs, ~140px out of line with the selects beside
   it, for as long as that form had existed — the reach was reasonable, the
   name was not.

   ⚠️ Same lesson as the 102 undefined domain-named classes in cbvipulse's HR
   page: a class named for its subject gets reinvented by the next app, because
   the next app's subject is different. Name the SHAPE. */
.ax-tile {
  display: grid;
  place-items: center;
  width: 5.5rem;
  height: 5.5rem;
  border: var(--ax-line);
  border-radius: var(--ax-r-loose);
  box-shadow: var(--ax-lift);
  background: var(--space, var(--surface3));
  font-weight: 900;
  font-size: var(--ax-fs-2xl);
  letter-spacing: -0.03em;
  color: var(--amber);
}
.ax-masthead h1 {
  margin: 0;
  font-weight: 900;
  line-height: 1.12;
}
.ax-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.85rem;
  grid-column: 1 / -1;
}
@media (min-width: 44rem) {
  .ax-meta {
    grid-column: 2;
  }
}
@media (max-width: 44rem) {
  .ax-masthead > * {
    min-width: 0;
  }
  .ax-masthead h1 {
    overflow-wrap: break-word;
  }
  .ax-tile {
    width: 4.25rem;
    height: 4.25rem;
    font-size: var(--ax-fs-xl);
  }
}

/* ══ Console — a question, an answer, and its sources ══════════════════════
   The shape a generated answer takes in this system. The answer body is set
   off by a left rule rather than a background fill, because a filled block
   reads as a quotation — someone else's words — and this is the product
   speaking. The sources are INSIDE the console, not in a footnote: an answer
   whose provenance is elsewhere on the page is an answer you have to take on
   trust, which is the opposite of the claim. */
.ax-console {
  border: var(--ax-line);
  border-radius: var(--ax-r-loose);
  box-shadow: var(--ax-lift);
  background: var(--surface);
  overflow: hidden;
}
.ax-console-bar {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  padding: 0.55rem 0.9rem;
  border-bottom: 2px solid color-mix(in srgb, var(--line) 60%, transparent);
  background: color-mix(in srgb, var(--space, var(--surface3)) 70%, transparent);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: var(--ax-label-track);
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-dot {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 999px;
  background: var(--accent);
  flex: none;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.ax-q {
  display: flex;
  gap: 0.7rem;
  padding: 1rem 1rem 0.8rem;
}
.ax-q .ax-caret {
  font-family: var(--ax-mono);
  font-weight: 900;
  color: var(--accent);
}
.ax-q p {
  margin: 0;
  font-weight: 700;
  font-size: var(--ax-fs-md);
}
.ax-ans {
  padding: 0 1rem 1rem;
  display: grid;
  gap: 0.75rem;
}
.ax-ans-body {
  border-left: 3px solid var(--amber);
  padding-left: 0.85rem;
  font-size: var(--ax-fs-md);
  line-height: 1.6;
  /* A generated answer is prose WITH paragraphs; without this a two-paragraph
     answer collapses to one (the cbvipulse sweep: the one thing blocking its
     handbook Q&A from adopting this component). */
  white-space: pre-wrap;
}
.ax-ans-body b {
  color: var(--amber);
}
.ax-console .ax-filters {
  padding: 0 1rem 1rem;
}

/* ══ Thread — two parties alternating ══════════════════════════════════════
   A two-sided message thread, where who spoke and when IS the content. The
   console above is the product speaking in one voice with its sources inside;
   nothing else in the vocabulary carried alternation, so three surfaces
   hand-rolled it three ways (Forge's rehearsal thread and its oven chat,
   Convo Ops' queue — cbapex#113). Promoted verbatim from the first of them.

   `is-self` is the SENDER'S hand — right-aligned on the accent mix — and
   which party that is belongs to the surface: the agent in a rehearsal, the
   operator in a queue, the person in a messenger. The other side keeps the
   neutral ground on the left. `is-note` is an annotation IN the flow, not a
   voice: dashed, full width, mono — a tool result, a hand-off, "held 41s".
   The bubble caps at 34rem so a long message stays a readable measure on a
   wide screen, and takes 92% under 30rem so a phone reads one column. */
.ax-thread {
  display: grid;
  gap: 0.55rem;
}
.ax-msg {
  box-sizing: border-box;
  max-width: min(88%, 34rem);
  padding: 0.6rem 0.8rem;
  border: var(--ax-line);
  border-radius: var(--ax-r);
  background: var(--space);
}
.ax-msg.is-self {
  justify-self: end;
  background: color-mix(in srgb, var(--accent) 12%, var(--surface));
  border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
}
.ax-msg.is-note {
  justify-self: stretch;
  max-width: none;
  border-style: dashed;
  background: transparent;
  color: var(--subdued);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-xs);
}
.ax-msg-who {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  margin-bottom: 0.3rem;
  font-family: var(--ax-mono);
  font-size: var(--ax-label-size);
  letter-spacing: var(--ax-label-track);
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-msg-body {
  white-space: pre-wrap;
  font-size: var(--ax-fs-sm);
  line-height: 1.55;
  color: var(--text);
}
/* What became of a message: sent, delivered, two segments, failed. Promoted
   from Convo Ops' `co-verdict` and cbsitrep's `msg-meta` (cbapex#168).

   ⚠️ THE LINE KEEPS ITS HEIGHT. A late "delivered" must never reflow the
   text somebody is reading. cbsitrep's rule, kept: leave the line in the
   bubble, empty, from the start.
   ⚠️ QUIET WHEN FINE. Plain facts are subdued. A word that needs attention is
   an `ax-state` inside the line, which carries weight as well as colour.
   ⚠️ IT IS NOT `is-note`. A note is an event IN the flow between messages;
   this is a property OF one message. */
.ax-msg-meta {
  /* A line of TEXT, not a flex row: measured, a long fact wrapped onto its
     own row, took its dot with it, and lost the sender's alignment. */
  display: block;
  min-width: 0;
  min-height: 1.5em;
  margin-top: 0.3rem;
  font-family: var(--ax-mono);
  /* 0.7rem, not the label size: lowercase mono at 9.9px is under what the
     line it replaces was read at. */
  font-size: var(--ax-fs-xs); /* a meta line, and no smaller than the 0.72rem verdict line it replaced */
  line-height: 1.5;
  letter-spacing: 0.04em;
  color: var(--subdued);
  overflow-wrap: anywhere;
}
.ax-msg-meta[hidden] {
  display: none;
}
.ax-msg.is-self > .ax-msg-meta {
  text-align: end;
}
/* The dot between facts is drawn, so a page joins nothing by hand and a
   fact that is absent leaves no stray separator. It trails the fact before
   it, so a line that wraps never STARTS with one. */
.ax-msg-meta > *:not(:last-child)::after {
  content: " · ";
  color: var(--subdued);
  font-weight: 400;
}
@media (max-width: 30rem) {
  .ax-msg {
    max-width: 92%;
  }
}

/* ══ SIGNATURE · Provenance trace ══════════════════════════════════════════
   The chain of what was fetched, what was checked, and what was thrown away.

   The load-bearing decision: **a refusal is a step, not an error state.** It
   keeps its place in the chain, in the refusal colour, with the reason
   attached. The instinct is to hide rejected work — it looks like failure —
   but a pipeline that only shows what it accepted is indistinguishable from
   one that accepts everything, and that difference is the product.

   Was a draft in the lab for months because it only ever existed as inline
   <style> in two standalone mockups. */
.ax-trace {
  border: var(--ax-line);
  border-radius: var(--ax-r);
  box-shadow: var(--ax-lift);
  overflow: hidden;
  background: var(--surface);
}
.ax-trace-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  justify-content: space-between;
  align-items: center;
  padding: 0.7rem 0.95rem;
  border-bottom: 2px solid color-mix(in srgb, var(--line) 60%, transparent);
  background: color-mix(in srgb, var(--space, var(--surface3)) 72%, transparent);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-trace-bar b {
  color: var(--amber);
  font-weight: 700;
}
.ax-steps {
  display: grid;
  padding: 0.35rem 0;
}
.ax-step {
  display: grid;
  /* ⚠️ `1fr` is minmax(auto, 1fr): the middle track's FLOOR is its longest
     word, so a 40-hex sha in a step label made the row 480px in a 358px
     column and the whole chef page scrolled sideways by 106px at 390
     (measured 2026-09-09). minmax(0, 1fr) lets the track shrink; the
     children break the token instead of pushing. */
  grid-template-columns: 1.75rem minmax(0, 1fr) auto;
  gap: 0.85rem;
  align-items: start;
  padding: 0.95rem 1rem;
  position: relative;
}
.ax-step > * {
  min-width: 0;
}
.ax-step-l,
.ax-step-t {
  overflow-wrap: anywhere;
}
.ax-step + .ax-step {
  border-top: 1px solid color-mix(in srgb, var(--line) 32%, transparent);
}
.ax-node {
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 999px;
  border: 2px solid var(--accent);
  background: var(--space, var(--surface3));
  margin-top: 0.15rem;
  justify-self: center;
  position: relative;
  z-index: 1;
}
.ax-step.is-done .ax-node {
  background: var(--accent);
}
.ax-step.is-blocked .ax-node {
  border-color: var(--red);
  background: var(--red);
}
/* The step that is happening NOW — the accent as a ring, not a fill, the way
   `.ax-dialog-step.is-on` marks the wizard's current step. The kit had the
   wizard form and not this one (cbvmod sweep). */
.ax-step.is-now .ax-node {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
/* The connector runs BEHIND the nodes (z-index above), so a filled node reads
   as a bead on a thread rather than as a break in it. */
.ax-step:not(:last-child) .ax-node::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 100%;
  width: 2px;
  height: calc(100% + 1.2rem);
  transform: translateX(-50%);
  background: color-mix(in srgb, var(--line) 70%, transparent);
}
.ax-step-l {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 0.3rem;
}
.ax-step.is-blocked .ax-step-l {
  color: var(--red);
}
.ax-step-t {
  margin: 0;
  font-size: var(--ax-fs-sm);
  line-height: 1.55;
  color: var(--subdued);
}
.ax-step-t strong {
  color: var(--text);
  font-weight: 700;
}
.ax-step-t code {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-sm);
  color: var(--info, var(--blue));
  word-break: break-all;
}
.ax-step-when {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  color: var(--subdued);
  white-space: nowrap;
  padding-top: 0.15rem;
}

/* ══ SIGNATURE · Overlap stack ═════════════════════════════════════════════
   Which boundaries contain which, drawn as spans on a shared axis so
   "crossing" is visible rather than asserted. The hatched `<u>` is the subject
   — every other bar is read against it — and `--x`/`--w` position a span while
   `--c` colours it, so a row is three custom properties and no bespoke CSS. */
.ax-stack {
  border: var(--ax-line);
  border-radius: var(--ax-r);
  box-shadow: var(--ax-lift);
  overflow: hidden;
  background: var(--surface);
}
.ax-stack-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  justify-content: space-between;
  align-items: center;
  padding: 0.7rem 0.95rem;
  border-bottom: 2px solid color-mix(in srgb, var(--line) 60%, transparent);
  background: color-mix(in srgb, var(--space, var(--surface3)) 72%, transparent);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-stack-bar b {
  color: var(--amber);
}
.ax-layers {
  display: grid;
  gap: 0.55rem;
  padding: 1.1rem 0.95rem;
}
.ax-layer {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.4rem;
}
.ax-layer-top {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: baseline;
  justify-content: space-between;
}
.ax-layer-name {
  font-weight: 700;
  font-size: var(--ax-fs-sm);
}
.ax-layer-name em {
  font-style: normal;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--subdued);
  display: block;
  margin-top: 0.15rem;
}
.ax-fit {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  padding: 0.22rem 0.45rem;
  border-radius: var(--ax-r-tight);
  border: 1px solid currentColor;
  white-space: nowrap;
}
.ax-fit.is-nested {
  color: var(--green);
}
.ax-fit.is-crossing {
  color: var(--red);
}
.ax-span {
  position: relative;
  height: 1.5rem;
  border-radius: var(--ax-r-tight);
  background: color-mix(in srgb, var(--space, var(--surface3)) 75%, transparent);
  border: 1px solid color-mix(in srgb, var(--line) 55%, transparent);
  overflow: hidden;
}
.ax-span i {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--x);
  width: var(--w);
  background: color-mix(in srgb, var(--c) 30%, transparent);
  border-left: 2px solid var(--c);
  border-right: 2px solid var(--c);
}
.ax-span u {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 37%;
  width: 26%;
  background: repeating-linear-gradient(
    45deg,
    color-mix(in srgb, var(--text) 8%, transparent) 0 4px,
    transparent 4px 8px
  );
  border-left: 2px dashed var(--amber);
  border-right: 2px dashed var(--amber);
  text-decoration: none;
}

/* ══ Ladder — ranked rows that carry a magnitude ═══════════════════════════
   Each rung's `--w` paints a proportional wash behind it, so the list is also
   a chart and does not need a second one beside it. The wash is BEHIND the
   text (`.ax-rung > *` is positioned), which is the difference between a
   readable row and a clever one. */
.ax-ladder {
  display: grid;
  gap: 0.45rem;
}
.ax-rung {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 0.75rem;
  border: 2px solid color-mix(in srgb, var(--line) 70%, transparent);
  border-radius: var(--ax-r);
  background: var(--surface);
  padding: 0.75rem 0.95rem;
  position: relative;
  overflow: hidden;
  transition: 0.15s;
  text-decoration: none;
  color: inherit;
}
.ax-rung:hover {
  border-color: var(--accent-hover);
  transform: translateX(3px);
}
.ax-rung::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: var(--w);
  background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 20%, transparent), transparent);
}
.ax-rung > * {
  position: relative;
}
/* Both are `display: block` rather than relying on the caller reaching for a
   <div>. Written as inline the title and its subtitle run together on one line
   — "TownshipsADMINISTERS ITS OWN" — which is a legible-looking bug: nothing
   overflows, nothing errors, and it reads as a styling opinion until someone
   looks at it next to the reference rendering. */
.ax-rung-t {
  display: block;
  font-weight: 800;
  font-size: var(--ax-fs-md);
}
.ax-rung-s {
  display: block;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--subdued);
  margin-top: 0.2rem;
}
.ax-rung-n {
  text-align: right;
  font-weight: 900;
  font-variant-numeric: tabular-nums;
  font-size: var(--ax-fs-md);
}
.ax-rung-n em {
  display: block;
  font-style: normal;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  font-weight: 400;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--amber);
  margin-top: 0.2rem;
}

/* THE RESULT ROW (cbapex#168) — a ladder INSIDE a card. The family audit filed
   cbnight's candidate row as a shape with no zoo row. It had one: name, number
   and a proportional wash behind them is a rung. What the rung lacked was the
   DENSITY — a race card holds three to eight candidates, and a bordered 56px
   rung each is a screen per race — and a way to say who leads.

   `is-compact`: no frame, a tight row, the caption beside the number instead
   of under it, and a NEUTRAL wash, so that in a list of bars the accent is
   spent on one row only. `is-lead` is that row: the accent wash and a bold
   name — weight as well as colour, because colour alone is not a state.
   shellRung() (shell.js) draws one and carries the three rules cbnight paid
   for: an unknown count is a dash and never 0, a zero share draws no bar, and
   a reported share is told from a derived one. */
.ax-ladder.is-compact {
  gap: 0.2rem;
}
.ax-ladder.is-compact .ax-rung {
  border: 0;
  border-radius: var(--ax-r-tight);
  background: none;
  padding: var(--shell-cell-y, 0.3rem) 0.5rem;
  font-size: var(--ax-fs-sm);
}
.ax-ladder.is-compact .ax-rung:hover {
  transform: none;
}
.ax-ladder.is-compact .ax-rung::before {
  background: color-mix(in srgb, var(--text) 9%, transparent);
  border-radius: var(--ax-r-tight);
}
.ax-ladder.is-compact .ax-rung-t {
  min-width: 0;
  overflow-wrap: anywhere;
  font-size: inherit;
  font-weight: 400;
}
.ax-ladder.is-compact .ax-rung-n {
  font-size: inherit;
  font-weight: 700;
  white-space: nowrap;
}
.ax-ladder.is-compact .ax-rung-n em {
  display: inline;
  margin: 0 0 0 0.4rem;
  font-size: var(--ax-fs-micro);
  text-transform: none;
  letter-spacing: 0;
  color: var(--subdued);
}
/* The caption's `display: inline` above is an authored display at (0,3,1), so
   `hidden` is restated beside it — for the caption, and for the ladder. */
.ax-ladder.is-compact .ax-rung-n em[hidden],
.ax-ladder.is-compact[hidden] {
  display: none;
}
.ax-rung.is-lead::before {
  background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 30%, transparent), transparent);
}
.ax-ladder.is-compact .ax-rung.is-lead::before {
  background: color-mix(in srgb, var(--accent) 24%, transparent);
}
.ax-rung.is-lead .ax-rung-t,
.ax-ladder.is-compact .ax-rung.is-lead .ax-rung-t {
  font-weight: 800;
}

/* ══ Ledger, continued — the wrapper the table needs ═══════════════════════
   `.ax-ledger` above is the table. These are the frame: a scroller so a wide
   table scrolls INSIDE its own box instead of widening the page (which breaks
   every layout below it on a phone), and a foot that states what fraction you
   are looking at. "5 of 83 shown" is not decoration — a truncated table with
   no count reads as the whole set. */
.ax-ledger-box {
  position: relative; /* the scroll cue below anchors here */
  border: var(--ax-line);
  border-radius: var(--ax-r);
  box-shadow: var(--ax-lift);
  overflow: hidden;
}
/* ⚠️ A SCROLLER THAT HIDES ITS SCROLLBAR HAS TO SAY THERE IS MORE (cbapex#76,
   measured again on cbsvi 2026-09-26: the rep table's third column header cut
   at the right edge, a "Who has not read" button half off-screen, every
   signup's email clipped — all inside a working scroller, so no overflow
   finding, and nothing on a 390px screen saying "swipe"). shell-controls.js
   marks each .ax-scroller with data-scroll-more="right" / "left" / "both"
   from scrollWidth vs clientWidth + scrollLeft; the box paints a fade on
   that edge. The pseudo sits on the BOX, not the scroller — a ::after inside
   an overflow container scrolls away with the content. Paint-only: the
   region is still the scroller, with its tabindex and label. */
.ax-ledger-box::before,
.ax-ledger-box::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2.25rem;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease;
}
.ax-ledger-box::before {
  left: 0;
  background: linear-gradient(to right, var(--surface, #0f1720) 15%, transparent);
}
.ax-ledger-box::after {
  right: 0;
  background: linear-gradient(to left, var(--surface, #0f1720) 15%, transparent);
}
.ax-ledger-box:has(> .ax-scroller[data-scroll-more="right"])::after,
.ax-ledger-box:has(> .ax-scroller[data-scroll-more="both"])::after,
.ax-ledger-box:has(> .ax-scroller[data-scroll-more="left"])::before,
.ax-ledger-box:has(> .ax-scroller[data-scroll-more="both"])::before {
  opacity: 1;
}
.ax-scroller {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}
/* ⚠️ A BARE .ax-scroller USED TO SQUEEZE INSTEAD OF SCROLL (cbapex#65).
   `.ax-ledger` is width:100%, so inside a scroller it shrank to fit — nothing
   overflowed, `scrollWidth === clientWidth`, and the page reported no problem
   while a 390px viewport crushed headers to 55px-tall one-character columns.
   The nowrap that prevents it was scoped to `.ax-ledger-box`, which no app
   used. min-width lets the table BE its content size so the scroller scrolls.

   ⚠️ TWO RULES, NOT ONE: a table you READ scrolls here; a table you OPERATE
   (a <select> per row) must STACK below its breakpoint instead — scrolling a
   control off the right edge is not an acceptable mobile answer (cbvmod's
   mapping table, the case that found this).

   ⚠️ AND THE REGION MUST BE KEYBOARD-REACHABLE: `overflow-x: auto` with no
   tabindex cannot be scrolled without a pointer. Every .ax-scroller in app
   HTML carries tabindex="0" role="region" aria-label — scroller-a11y.test.ts
   holds the app to it. */
.ax-scroller .ax-ledger {
  min-width: max-content;
}
.ax-scroller .ax-ledger th {
  white-space: nowrap;
}
.ax-ledger-box .ax-ledger th {
  /* Tracks the density dial like the cells do (cbrural-b1, 2026-10-09). A fixed 0.7rem
     here outranked the dial at (0,2,1), so cbrural measured cells moving
     4.8 → 12.8px and headers flat at 11.2px. Offset from the cell token so
     the DEFAULT is exactly the old 0.7rem (0.55 + 0.15). */
  padding: calc(var(--shell-cell-y, 0.55rem) + 0.15rem) 0.9rem;
  background: color-mix(in srgb, var(--space, var(--surface3)) 75%, transparent);
  white-space: nowrap;
}
.ax-ledger-box .ax-ledger td {
  background: var(--surface);
}
.ax-ledger-box .ax-ledger tbody tr:last-child td {
  border-bottom: 0;
}
.ax-ledger-box .ax-ledger tbody tr:hover td {
  background: color-mix(in srgb, var(--amber) 7%, var(--surface));
}
/* ⚠️ THE BOX IS A TABLE'S FRAME, AND A CARDED TABLE IS NOT A TABLE (operator,
   2026-09-29, reading Save VI's Signups on a phone: "each of these card
   panels has a leftover parent panel"). Below 40rem `ax-worklist-cards` turns
   each row into a card with its own border. The box around it went on
   drawing the table's frame: a second border and a shadow round the whole
   list, `overflow: hidden` clipping the cards' corners, a filled header
   cell, and a hover tint meant for a row. Nothing overflowed, so no audit
   finding. Here the box stands down and the card is the only frame.
   Above 40rem nothing changes. */
@media (max-width: 40rem) {
  .ax-ledger-box:has(> .ax-scroller > .ax-worklist-cards),
  .ax-ledger-box:has(> .ax-worklist-cards) {
    border: 0;
    border-radius: 0;
    box-shadow: none;
    overflow: visible;
  }
  .ax-ledger-box .ax-ledger.ax-worklist-cards tr {
    background: var(--surface);
  }
  .ax-ledger-box .ax-ledger.ax-worklist-cards th,
  .ax-ledger-box .ax-ledger.ax-worklist-cards td,
  .ax-ledger-box .ax-ledger.ax-worklist-cards tbody tr:hover td {
    background: none;
  }
  .ax-ledger-box .ax-ledger.ax-worklist-cards th {
    padding: 0 0 0.15rem;
    white-space: normal;
  }
  .ax-ledger-box .ax-ledger.ax-worklist-cards tbody tr:last-child {
    margin-bottom: 0;
  }
}
.ax-ledger .name {
  font-weight: 700;
  white-space: nowrap;
}

/* ══ Pinned — the first column stays while the rest scrolls (cbapex#168) ══
   `ax-pinned` on a table inside an `.ax-scroller`. Promoted from cbsvi, which
   carried it twice (the pay-period grid and Signups), and whose report is
   the reason: "i have to scroll to see the end totals on columns but then i
   can no longer see the people names" (cbsvi#60). A row scrolled away from
   its own name is a row of anonymous numbers.

   ⚠️ AN OPAQUE BACKGROUND IS THE LOAD-BEARING PART. The box's header fill is
   75% and everything that scrolls shows straight through it. cbsvi's first
   check of this passed on the broken build: it looked for the word
   "transparent", and a colour-mix computes to `color(srgb … / 0.75)`. READ
   THE ALPHA.
   ⚠️ THE HEAD'S CORNER SITS ABOVE THE BODY'S, or a row header scrolls up
   over it.
   ⚠️ THE FIRST CELL SHOULD BE `th scope="row"`. The cell that names its row
   IS a row header; the pin and a screen reader want the same element. A
   `td` pins too, so a table that has not caught up still works.
   ⚠️ NOT A CELL THAT SPANS. The reason under a row and the detail beneath it
   are first children too, and they are the whole width.
   ⚠️ THE NAME MAY WRAP AND IS CAPPED AT 45% OF ITS OWN BOX, NOT OF THE
   SCREEN. The first cut said `45vw`, and the screen is the wrong measure:
   at 1440 a pinned table in a 380px column got a 391px name column, 103% of
   its box, which covers every other column (measured; cbsvi's agent saw the
   cap exceeded first). The scroller is made a container and the cap is
   `45cqi`. `45vw` stays as the fallback where container units are missing.
   ⚠️ IT IS NOT `ax-worklist-sticky`, which pins the LAST column, an action.
   A table may wear both. Under `ax-worklist-cards` on a phone there are no
   columns, and this stands down. */
.ax-pinned tr > :first-child:not([colspan]) {
  position: sticky;
  left: 0;
  z-index: 2;
  /* ⚠️ STATED HERE, NOT BORROWED. `border-box` comes from loom.css's reset,
     a vendored third-party file. Without it the cap measures the CONTENT and
     the cell renders its padding and border wider: 29px over, 54% of the box
     where 45% was asked (measured with and without that sheet). */
  box-sizing: border-box;
  max-width: 45vw;
  background: var(--surface);
  border-right: 1px solid var(--line);
  box-shadow: 6px 0 8px -6px rgb(0 0 0 / 40%);
  white-space: normal;
  /* ⚠️ `break-word`, NOT `anywhere`. `anywhere` lowers the cell's minimum
     width to ONE CHARACTER, and a table that is squeezed takes it: measured
     on a Loom table, the name ran down the screen a letter a line and three
     rows were 665px tall. `break-word` breaks a long token only when it
     must, and leaves the minimum at the longest word. */
  overflow-wrap: break-word;
}
/* …and the table is as wide as its content, so the scroller scrolls and
   nothing is squeezed. `.ax-ledger` already is; a Loom table is not. */
.ax-scroller .ax-pinned {
  min-width: max-content;
}
/* ⚠️ ONLY a scroller that holds a pinned table. Containment makes a box's
   width ignore its content, which is right for a block that fills its
   parent and would collapse a scroller sized by what is in it. */
.ax-scroller:has(> .ax-pinned) {
  container-type: inline-size;
}
@supports (width: 1cqi) {
  .ax-pinned tr > :first-child:not([colspan]) {
    max-width: 45cqi;
  }
}
.ax-pinned thead tr > :first-child:not([colspan]) {
  z-index: 3;
}
/* A row's state tint is 9% OVER TRANSPARENT, which a pinned cell cannot
   wear: the columns would show through it. The same tint, mixed into the
   surface, so the row reads as one row. */
.ax-pinned tr.is-bad > :first-child:not([colspan]) {
  background: color-mix(in srgb, var(--red, #f14668) 9%, var(--surface));
}
.ax-pinned tr.is-warn > :first-child:not([colspan]) {
  background: color-mix(in srgb, var(--amber, #ffba55) 9%, var(--surface));
}
.ax-pinned tr.is-ok > :first-child:not([colspan]) {
  background: color-mix(in srgb, var(--green, #3ddc97) 7%, var(--surface));
}
/* The box tints a hovered ROW; a pinned cell keeping its own fill breaks the
   row in half under the cursor. */
.ax-ledger-box .ax-pinned tbody tr:hover > :first-child:not([colspan]) {
  background: color-mix(in srgb, var(--amber) 7%, var(--surface));
}
/* The box fades its LEFT edge to say there is more that way. Over a pinned
   column that is a fade across the names. */
.ax-ledger-box:has(> .ax-scroller .ax-pinned)::before {
  opacity: 0;
}
/* …and its RIGHT edge, over a pinned action. */
.ax-ledger-box:has(> .ax-scroller .ax-worklist-sticky)::after {
  opacity: 0;
}
@media (max-width: 40rem) {
  .ax-worklist-cards.ax-pinned tr > :first-child:not([colspan]) {
    position: static;
    max-width: none;
    background: none;
    border-right: 0;
    box-shadow: none;
  }
}
.ax-tfoot {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  justify-content: space-between;
  align-items: center;
  padding: 0.9rem;
  background: color-mix(in srgb, var(--space, var(--surface3)) 70%, transparent);
  border-top: 2px solid color-mix(in srgb, var(--line) 60%, transparent);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--subdued);
}
.ax-tfoot a {
  color: var(--amber);
  text-decoration: none;
  font-weight: 700;
  /* Same as .ax-crumb a: a 13px link padded to the 24px hit floor, layout
     unchanged (cbapex#129). */
  padding-block: 0.35rem;
  margin-block: -0.35rem;
}

/* A format/kind marker inside a ledger cell. Outlined in its own colour and
   ALWAYS carrying its text — `JSON`, `PDF` — so the colour is a second signal
   rather than the only one. */
.ax-kind {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.05em;
  padding: 0.22rem 0.45rem;
  border-radius: var(--ax-r-tight);
  border: 1px solid currentColor;
  white-space: nowrap;
  color: var(--subdued);
}
.ax-kind.is-1 {
  color: var(--info, var(--blue));
}
.ax-kind.is-2 {
  color: var(--amber);
}
.ax-kind.is-3 {
  color: var(--red);
}
.ax-kind.is-4 {
  color: var(--purple, var(--accent));
}

/* ══ Key/value list ════════════════════════════════════════════════════════
   Keys mono-uppercase and quiet, values bold and right-aligned. Below 44rem
   they STACK rather than squeeze — a two-column row whose value wraps to three
   lines is worse than two lines that read in order. */
.ax-kv {
  display: grid;
  gap: 0.7rem;
  font-size: var(--ax-fs-sm);
  margin: 0;
}
.ax-kv > div {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding-bottom: 0.7rem;
  border-bottom: 1px solid color-mix(in srgb, var(--line) 35%, transparent);
}
.ax-kv > div:last-child {
  border-bottom: 0;
  padding-bottom: 0;
}
.ax-kv dt {
  color: var(--subdued);
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.ax-kv dd {
  margin: 0;
  font-weight: 700;
  text-align: right;
}
@media (max-width: 44rem) {
  .ax-kv > div {
    flex-direction: column;
    gap: 0.15rem;
  }
  .ax-kv dd {
    text-align: left;
  }
}

/* ══ Meter, continued — the partial fill ═══════════════════════════════════
   Hatched, not a lighter shade. A provisional or partial number that differs
   from a verified one only in saturation is a number that will be quoted as
   verified. The hatch survives a screenshot, a projector and a colourblind
   reader; a tint survives none of them. */
.ax-meter.is-partial > span {
  background: repeating-linear-gradient(
    45deg,
    var(--amber) 0 5px,
    color-mix(in srgb, var(--amber) 33%, transparent) 5px 10px
  );
}
.ax-meter-row {
  display: grid;
  gap: 0.4rem;
}
.ax-meter-top {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  color: var(--subdued);
}
.ax-meter-top b {
  color: var(--text);
}

/* ══ Dates — never claim precision you cannot date ═════════════════════════
   A date tile plus what it governs. The tile is mono and bordered rather than
   filled, because a filled date reads as an event badge and these are
   deadlines, which are facts about the calendar and not about us. */
/* Rows are the work; the gap between GROUPS has to be bigger than the gap
   inside one, or the last row of one date crowds the next date. `--ax-dates-gap`
   so a consumer can tune the rhythm without restating the grid: set it on
   `.ax-dates` itself (a more specific rule, or the element's style attribute).
   ⚠️ Declared HERE, with its default, not left to a `var()` fallback: a token
   defined nowhere looks like a dial and is a literal, and this one carried two
   different literals under one name (cbapex#176; tokens-exist.test.ts). */
.ax-dates {
  --ax-dates-gap: 1.5rem;
  display: grid;
  gap: var(--ax-dates-gap);
}
.ax-date {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.8rem;
  /* `center`, not `start` (operator: "align with center of title"). The date
     labels the row beside it, so it sits level with it rather than clinging to
     the top of a group that may be four rows tall. */
  align-items: center;
}
/* ⚠️ NO BOX (operator, 2026-08-22: "remove box from date").
   This carried a 2px border and a radius, on the reasoning at the top of the
   block: "the tile is mono and bordered rather than filled, because a filled
   date reads as an event badge". The argument was against a FILL and it bought
   an outline nobody asked for — and once the rail's own rows are bordered
   cards, a bordered date beside them is two boxes competing to be the thing
   you look at. Mono, letterspaced and accent-coloured is already enough to
   read as a date; that is how every other label in this file works. */
.ax-date-d {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.06em;
  color: var(--amber);
  padding: 0;
  text-align: center;
  line-height: 1.3;
  white-space: nowrap;
}
.ax-date-t {
  font-size: var(--ax-fs-sm);
  font-weight: 700;
}
.ax-date-t em {
  display: block;
  font-style: normal;
  font-weight: 400;
  color: var(--subdued);
  font-size: var(--ax-fs-xs);
  margin-top: 0.15rem;
}
/* Optional halves of the tile, for a rail that spans more than one year. Two
   spans rather than a `<br>` so the phone layout below can put them on one
   line — a `<br>` cannot be un-broken in CSS without joining the words. The
   plain single-text-node form still works; these are additive. */
.ax-date-m,
.ax-date-y {
  display: block;
}
.ax-date-y {
  color: color-mix(in srgb, var(--amber) 70%, transparent);
}
/* Today is the row a person is looking for, so it is marked rather than left
   to be counted out. Nothing moves — only the tile changes weight. */
/* Today, marked without putting the box back: the label goes bright and heavy
   instead. Weight and colour, never colour alone. */
.ax-date.is-today > .ax-date-d {
  color: var(--text);
  font-weight: 700;
}
.ax-date-now {
  display: block;
  font-weight: 700;
  letter-spacing: 0.08em;
}

/* ── The section label, riding the first group's date line ─────────────────
   A rail is usually introduced by a heading ABOVE it ("COUNTING NOW", then the
   dates). That spends a whole line on two words and puts the label further from
   the rows it labels than the date is. Operator, 2026-08-22: put it in line
   with the date, to the right, with a little space between them.

   So the label becomes a child of the FIRST `.ax-date` and shares the tile's
   line, pushed to the far edge. The rail keeps its shape; nothing else moves.

   Position only — the TYPOGRAPHY stays the app's, because a section label
   should look like that app's other section labels (cbnight passes its own
   `nt-panel-h` alongside). Deliberately no `display` in this block: these
   selectors sit at (0,3,0), above the `[class^="ax-"][hidden]` guard, so a
   display here would out-specify `hidden`.

   `:last-child` rather than a named content class: what the rail holds is the
   consumer's business, and the kit should not need to know its class name. */
.ax-date.has-sect > .ax-date-d {
  grid-column: 1;
  grid-row: 1;
}
.ax-date.has-sect > .ax-date-sect {
  grid-column: 2;
  grid-row: 1;
  justify-self: end;
  align-self: center;
  margin: 0;
}
.ax-date.has-sect > :last-child {
  grid-column: 2;
  grid-row: 2;
}

/* A two-column date rail spends ~4.5rem of a 390px screen on a tile six
   characters wide, and leaves a ragged empty gutter beside every group that
   has more than one row — which is most of them. Below 40rem the tile becomes
   the group's HEADER instead of its left column: same information, same
   order, and the full width goes to the thing being dated. */
@media (max-width: 40rem) {
  /* MORE room between groups here, not less. With the tile promoted to a
     header, the previous group's last row sits directly above the next date
     with nothing between them — "add space below entry so it doesn't collide
     with the date" (operator, 2026-08-22). */
  .ax-dates {
    --ax-dates-gap: 1.7rem;
  }
  .ax-date {
    grid-template-columns: minmax(0, 1fr);
    gap: 0.45rem;
  }
  .ax-date-d {
    justify-self: start;
    display: flex;
    gap: 0.4rem;
    align-items: baseline;
  }
  .ax-date-m,
  .ax-date-y,
  .ax-date-now {
    display: inline;
  }
  /* The tile stops being a full-width header for a LABELLED group only: it
     shares that line with the section label, which is the whole point of the
     arrangement. The content below still gets the full width. */
  .ax-date.has-sect {
    grid-template-columns: auto minmax(0, 1fr);
    column-gap: 0.9rem;
  }
  .ax-date.has-sect > :last-child {
    grid-column: 1 / -1;
    grid-row: 2;
  }
}

/* ══ Deltas — what changed, and how badly ══════════════════════════════════
   A run-over-run diff. Every row is labelled with what KIND of change it is
   (`is-break` / `is-fix`) because "changed" on its own is not actionable, and
   the severity chip is outlined in its own colour rather than filled so a page
   of three severities does not turn into a traffic light. */
.ax-deltas {
  border: var(--ax-line);
  border-radius: var(--ax-r);
  box-shadow: var(--ax-lift);
  overflow: hidden;
  background: var(--surface);
}
.ax-delta {
  display: grid;
  gap: 0.7rem;
  padding: 1.25rem 1.1rem;
  border-bottom: 2px solid color-mix(in srgb, var(--line) 45%, transparent);
}
.ax-delta:last-child {
  border-bottom: 0;
}
.ax-delta-head {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  align-items: baseline;
  justify-content: space-between;
}
.ax-delta-n {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: var(--ax-label-track);
  text-transform: uppercase;
  color: var(--accent);
}
.ax-delta-title {
  font-weight: 800;
  font-size: var(--ax-fs-md);
  flex: 1 1 14rem;
}
.ax-sev {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  padding: 0.22rem 0.45rem;
  border-radius: var(--ax-r-tight);
  border: 1px solid currentColor;
  white-space: nowrap;
  color: var(--subdued);
}
.ax-sev.is-high {
  color: var(--red);
}
.ax-sev.is-med {
  color: var(--amber);
}
.ax-sev.is-low {
  color: var(--info, var(--blue));
}
.ax-delta-rows {
  display: grid;
  gap: 0.5rem;
}
/* ⚠️ THE ROW MUST BE ABLE TO SHRINK, AND IT COULD NOT (cbapex#81) ═══════════
   `1fr` is `minmax(auto, 1fr)`, and `auto` floors the track at its content's
   MIN-CONTENT width. Handed something unbreakable — a filesystem path, an email
   address, a run id — the track refuses to shrink and the row grows past its
   container. Same family as cbapex#56's wordmark and `.cn-rec`'s card, third
   instance.

   ⚠️ ONE CAUSE, TWO FAILURE MODES, and the quiet one is worse. Measured at
   390px across all eleven apps on 2026-08-27:

     cbconnector Month Close   no clipping ancestor  → the PAGE scrolled 198px
     every app's preview.html  `.ax-deltas` clips    → 238px SILENTLY CUT OFF

   `.ax-deltas` sets `overflow: hidden` for its border-radius, so the same
   defect reads as a layout bug in one app and as *missing text* in ten. Nobody
   would have connected them, and the family's own overflow gate cannot see the
   second one at all (cbapex#83).

   ⚠️ BOTH declarations are load-bearing and one is not enough. `min-width: 0`
   alone brings the ROW back inside its box (580px → 303px) and leaves the TEXT
   spilling 277px out of its 204px cell, into the same `overflow: hidden` — the
   truncation moves inward and stays invisible. Measured both ways.

   ⚠️ `anywhere`, not `break-word`. They render identically here BECAUSE
   `min-width: 0` is doing the sizing work; `anywhere` also affects intrinsic
   min-content, so it keeps working if the `min-width` is ever lost. The rule
   that does not silently stop working is the one to ship.

   ⚠️ `:not(.k)` is deliberate. `.ax-delta-row .k` sets `min-width: 5.5rem` for
   a consistent label column, and a blanket `> *` would fight it — it happens to
   survive on specificity (0,2,0 beats 0,1,0), which is exactly the kind of
   thing that reads as a bug and gets "fixed". State the exclusion instead. */
.ax-delta-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 0.7rem;
  align-items: start;
  font-size: var(--ax-fs-sm);
  line-height: 1.55;
  color: var(--subdued);
  min-width: 0;
}
.ax-delta-row > *:not(.k) {
  min-width: 0;
  overflow-wrap: anywhere;
}
.ax-delta-row .k {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-micro);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  padding: 0.25rem 0.45rem;
  border-radius: var(--ax-r-tight);
  white-space: nowrap;
  min-width: 5.5rem;
  text-align: center;
}
.ax-delta-row.is-break .k {
  color: var(--red);
  border: 1px solid var(--red);
  background: color-mix(in srgb, var(--red) 10%, transparent);
}
.ax-delta-row.is-fix .k {
  color: var(--green);
  border: 1px solid var(--green);
  background: color-mix(in srgb, var(--green) 10%, transparent);
}
.ax-delta-row strong {
  color: var(--text);
  font-weight: 700;
}
.ax-delta-row code {
  font-family: var(--ax-mono);
  font-size: var(--ax-fs-sm);
  color: var(--info, var(--blue));
}

/* ══ Touch targets ═════════════════════════════════════════════════════════
   On a coarse pointer the small mono tokens you can PRESS grow to a real tap
   target. This is a `pointer: coarse` query and not a width query on purpose:
   a tablet at 1024px is a touch device, and a narrow desktop window is not.

   ⚠️ A FLOOR BELONGS ON THINGS YOU CAN PRESS (cbapex#154). This used to size
   every `.ax-tag`, `.ax-chip` and `.ax-src`, and those are STATEMENTS — the
   zoo's own rule is that a tag with an onclick is the give-away. So on every
   phone in the family each fact became a 38px box around 10px text (measured
   by cbprospect on a Pixel 7: a touch-count chip in a list row at h=38.39,
   1.6px padding), growing every row that carried one. `.ax-filter` is always a
   `<button aria-pressed>`; the other three get the floor only in their
   pressable FORMS — an `<a>`, a `<button>`, a `<label>` wrapping an input
   (cbvipulse's handbook-page chip, cbadmin's are real buttons). Element +
   class keeps these at (0,1,1), under the `[hidden]` guard. */
@media (pointer: coarse) {
  .ax-filter,
  a.ax-tag,
  button.ax-tag,
  label.ax-tag,
  a.ax-chip,
  button.ax-chip,
  label.ax-chip,
  a.ax-src,
  button.ax-src,
  label.ax-src {
    min-height: 2.4rem;
    display: inline-flex;
    align-items: center;
  }
  .ax-srclist a {
    min-height: 2.4rem;
    align-items: center;
  }
}

/* The kit's small PRESSABLE controls at a phone width or under a coarse pointer
   (APEX-COMPOSE Q0.6, 2026-10-09): the icon button (36px), the rolodex's
   letters (32px), the file row's action (36px) and the signature box's input
   reach the 44px touch floor. Width OR pointer, like shell.css's chrome rule:
   a phone is a touch device whatever its pointer reports, and a tablet is one
   at any width. The paint grows with the box here because these sit in rows
   of their own kind, where an invisible hit area would overlap a neighbour's. */
@media (max-width: 40rem), (pointer: coarse) {
  .ax-iconbtn {
    width: 2.75rem;
    height: 2.75rem;
  }
  .ax-rolodex-l {
    min-width: 2.75rem;
    min-height: 2.75rem;
    line-height: 2.75rem;
  }
  .ax-filerow > .ax-btn,
  .ax-hand-input {
    min-height: 2.75rem;
  }
  /* THE RAISED FILTER STRIP (operator, cbapex#175 item 4, option A): the
     chip stays 32px (38 under touch) as a dense row of secondary toggles,
     and a PAGE raises it where chips are its main control — by saying so
     with `is-main` on the strip, not with a local rule that shadows the
     kit's class (cbrural's worklist chips measured 38px tall under touch). The
     decision promised the zoo would show this form; it does now. */
  /* min-height only: a button centres its own label, and a `display` here at
     (0,3,0) would beat the [hidden] guard (components-hidden.test.ts). */
  .ax-filters.is-main .ax-filter {
    min-height: 2.75rem;
  }
}

/* ══ The person card ═══════════════════════════════════════════════════════
   A card that stands for a PERSON you might contact — ported from cbprospect,
   where three views each grew their own card shape and the operator's verdict
   was "a hot mess" (2026-08-17). One anatomy, spec'd as structure:

     - a small quiet CLOCK top-right (their local time — never a colored box;
       words carry the verdict so nothing rides on hue)
     - the SEAT/ROLE second-loudest after the name
     - a BADGE band and a REACH band, each opened by a hairline — the
       separation the pile was missing
     - a Phone line and an Email line ALWAYS, labels aligned; a gap renders
       as stated words ("none yet"), because "no email" is a fact someone
       acts on — and never an em dash, which trips the copy sentinel
     - the primary action pinned bottom-right (margin-top:auto is the pin)
     - the ENTIRE card a door, with a tap-guard so inner links still work:
         onclick="if(!event.target.closest('a,button'))NAVIGATE"

   Reference implementation of the JS half: personCard() in cbprospect's
   prospect.html — one renderer that every view feeds, because three drifting
   copies is what produced the mess.

   ⚠️ If your app re-declares card padding through density tokens LATER in the
   cascade, single-class rules here lose silently — cbprospect measured its
   clock band collapsing to the token value. The two-class selector below is
   that lesson. */
.ax-person {
  position: relative;
  /* Column flex is what lets the foot pin to the card's floor with
     margin-top:auto — ax-card alone is not a flex container. */
  display: flex;
  flex-direction: column;
}
.ax-card.ax-person,
.ax-person.ax-person {
  padding-top: calc(var(--shell-box-y, 0.85rem) + 1.05rem);
}
/* The clock's FACE is the component's, wherever it sits: a quiet mono fact,
   never a lamp. It lived only on `.ax-person > .ax-clock` until cbapex#154's
   rider, so a clock anywhere else (cbprospect draws it in five places: a
   contact page head, a callbacks list, a table row) rendered at body size and
   every such app restated these numbers. The person card adds PLACEMENT only. */
.ax-clock {
  font: 600 var(--ax-fs-micro) / 1 var(--ax-mono);
  letter-spacing: 0.04em;
  white-space: nowrap;
}
.ax-person > .ax-clock {
  position: absolute;
  top: 0.6rem;
  right: 0.7rem;
}
/* The VERDICT on the clock (cbapex#154, promoted from cbprospect's `pr-tz`):
   is it polite to call them now? On the chip scale — the same --green / --red
   an `.ax-chip.is-ok` / `.is-bad` wears — and still never a coloured box.
   ⚠️ THE WORDS CARRY THE STATE, the colour only repeats it: `is-ok` reads
   "12:45 PM their time", `is-bad` reads "too early" / "too late", with the
   window ("calls 9:00 to 20:00 their time") in the `title`. Colour alone is
   not a signal a colour-blind rep, or a greyscale screenshot, receives. */
.ax-clock.is-ok {
  color: var(--green);
}
.ax-clock.is-bad {
  color: var(--red);
  font-weight: 800;
}
.ax-person-seat {
  color: var(--text, #fff7ec);
  font-size: var(--ax-fs-sm);
  font-weight: 700;
  line-height: 1.35;
  margin-top: 0.1rem;
}
.ax-person-where {
  font-size: var(--ax-fs-xs);
  color: var(--subdued);
  margin-top: 0.15rem;
}
.ax-person-badges {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem;
  margin-top: 0.55rem;
  padding-top: 0.55rem;
  border-top: 1px solid color-mix(in srgb, var(--line, #5f4c66) 55%, transparent);
}
.ax-person-reach {
  display: grid;
  gap: 0.3rem;
  margin-top: 0.55rem;
  padding-top: 0.55rem;
  border-top: 1px solid color-mix(in srgb, var(--line, #5f4c66) 55%, transparent);
  font-size: var(--ax-fs-sm);
}
.ax-reach-line {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  min-width: 0;
}
.ax-reach-line a {
  /* 16px links padded to the 24px hit floor, layout unchanged — the
     tap-target check's first cycle (cbapex#129). */
  padding-block: 0.3rem;
  margin-block: -0.3rem;
}
/* ⚠️ ELIDE, NEVER BREAK MID-WORD (cbapex#154). This was `overflow-wrap:
   anywhere`, which split `karl@bergstromformichigan.co / m` at a point that
   means nothing — the operator's "jumbled" report. An address is one token: it
   stays on one line and loses its END to an ellipsis, and the full value is a
   tap away (the link itself, or the contact card) — put it in `title` too.
   `min-width: 0` is what lets a flex child shrink below its content at all. */
.ax-reach-line a,
.ax-reach-line > span:not(.ax-reach-l) {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.ax-reach-l {
  flex: 0 0 3rem;
  font: 700 var(--ax-fs-micro)/1.6 var(--ax-mono);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted, var(--subdued));
  opacity: 0.8;
}
/* The action floor. Requires the card to be a column flex (ax-card is);
   margin-top:auto parks every card's action on the same line in a grid row. */
.ax-person-foot {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
  margin-top: auto;
  padding-top: 0.65rem;
}
/* cbprospect's card used `btn-sm` here; a 46px button on a card's floor is the
   loudest thing on it. Same numbers as the other compact containers. */
.ax-person-foot .ax-btn {
  min-height: 1.75rem;
  padding: 0.25rem 0.7rem;
  font-size: var(--ax-fs-xs);
}

/* ══ Share & hand-off ══════════════════════════════════════════════════════
   The family's three shapes of "give someone this", named so they stop being
   re-derived per page (elevated from the kit invite screen, 2026-08-18):

     - the SHARE BUTTON — copies a deep link to what is on screen. Vendored
       loom.js already carries it (`loomShareBtn`); mount that, don't rebuild.
     - the HAND-OFF ROW below — a payload in a readonly input beside its Copy
       button, for when the reader must carry the thing somewhere else
       (an invite link the mail could not deliver, a join code).
     - the COPY FIELD — a small Copy button beside a printed value
       (`loomCopy` does the clipboard part, with select() as the fallback).

   The row is the one that lived only as page-local CSS; now it is shared. */
.ax-hand {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}
.ax-hand-input {
  flex: 1 1 auto;
  min-width: 0;
  font-family: var(--ax-mono, ui-monospace, monospace);
  font-size: var(--ax-fs-xs);
  padding: 0.45rem 0.6rem;
  border: var(--ax-line);
  border-radius: var(--ax-r-tight, 4px);
  background: var(--space);
  color: var(--text);
}

/* ── A SETTING ROW, AND ITS SWITCH ──────────────────────────────────────────
 *
 * Added 2026-08-21 with the preferences surface ported from cbloom's
 * `user.html`. In the Zoo rather than on that page because the operator's
 * standing rule is that a component used several times belongs here — and a
 * settings screen is a dozen of these on its own, before any app grows one.
 *
 * The shape: a label and a sentence of explanation on the left, one control on
 * the right, a rule between rows. Same anatomy as loom's `.toggle-row`,
 * rebuilt in `ax-*` because the Apex frame is not loom's chrome.
 */
.ax-setting {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.75rem 0;
  border-bottom: 1px solid var(--border);
}
.ax-setting:last-child { border-bottom: 0; }
.ax-setting-l { flex: 1 1 auto; min-width: 0; }
.ax-setting-t {
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex-wrap: wrap;
}
.ax-setting-s {
  font-size: var(--ax-fs-sm);
  color: var(--subdued);
  margin-top: 0.15rem;
}
/* The control column never shrinks below its content — a wrapped switch reads
   as a broken row. */
.ax-setting-c { flex: 0 0 auto; }

/* ── The switch ─────────────────────────────────────────────────────────────
 * A REAL checkbox, visually replaced. Not a div with an onclick: this is the
 * accessibility panel, so the control that turns on high contrast has to be
 * reachable by keyboard, announced as a checkbox, and toggled by Space.
 */
.ax-switch { display: inline-flex; align-items: center; cursor: pointer; }
.ax-switch input {
  /* Clipped rather than `display:none` — a hidden input is not focusable, and
     an unfocusable switch is a switch a keyboard cannot reach. */
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}
.ax-switch-track {
  position: relative;
  width: 2.6rem;
  height: 1.4rem;
  border-radius: 999px;
  background: var(--border);
  border: 1px solid var(--border);
  transition: background 0.15s ease;
}
.ax-switch-track::after {
  content: "";
  position: absolute;
  top: 0.15rem;
  left: 0.15rem;
  width: 1rem;
  height: 1rem;
  border-radius: 50%;
  background: var(--text);
  transition: transform 0.15s ease;
}
.ax-switch input:checked + .ax-switch-track { background: var(--accent); border-color: var(--accent); }
.ax-switch input:checked + .ax-switch-track::after {
  transform: translateX(1.2rem);
  background: var(--n-accent-foreground, #fff);
}
/* The focus ring goes on the TRACK, because the input it belongs to is clipped
   to a pixel. Without this the keyboard path is invisible. */
.ax-switch input:focus-visible + .ax-switch-track {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.ax-switch input:disabled + .ax-switch-track { opacity: 0.45; }
.ax-switch input:disabled ~ * { cursor: not-allowed; }
/* A reader who asked for less motion is not shown a sliding knob — and this
   panel is where they turn that on. */
@media (prefers-reduced-motion: reduce) {
  .ax-switch-track,
  .ax-switch-track::after { transition: none; }
}

/* A range with its value visible. A slider whose number is not shown is a
   slider nobody can set deliberately. */
.ax-setting-range { display: flex; align-items: center; gap: 0.6rem; }
.ax-setting-range input[type="range"] { width: 9rem; accent-color: var(--accent); }
.ax-setting-v {
  font-family: var(--ax-mono);
  font-size: var(--ax-label-size);
  color: var(--subdued);
  min-width: 3.2ch;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* ══ Choice — pick one, with the reason on the row (cbapex#97) ══════════════
   The MIRROR of `.ax-setting` above, and the kit had only that one: a setting
   is a title and a sentence on the LEFT with one control on the RIGHT ("High
   contrast [switch]"). A choice is the other way round — the control LEADS,
   because the reader is scanning a column of radios to pick one, and each
   option carries a title, an optional badge, and the RATIONALE for picking it.

   Promoted from cbvipulse's `wz-alt-row` (the write-up wizard's severity
   options: "First written warning · Recommended · ✓ proportionate · ✗ no paper
   trail"). ⚠️ cbvmod's `wz-opt` is the same DECISION in a different CONTROL — a
   deck of buttons, not a radio list — so it is deliberately NOT folded in here.
   One shape per control; a component that is a radio and a button depending on
   a modifier is two components sharing a name.

   ⚠️ THE ROW CARRIES THE SELECTED STATE, NOT JUST THE DOT. `wz-alt-row` showed
   selection only in the native radio, which on a phone is a 13px circle at the
   left edge of a three-line block — the eye has to hunt for it to answer "which
   one is chosen?". `:has(input:checked)` tints and rules the whole row, so the
   answer is the shape of the block. Colour is not the only signal: the border
   moves too.

   ⚠️ AN OPTION THE READER CANNOT PICK IS SHOWN AND DISABLED, NOT HIDDEN. That
   is cbvmod's rule and it carries up verbatim — "you lack a grant" beats a
   silently shorter list, because a list that quietly omits things cannot be
   distinguished from a list that is complete. */
.ax-choices {
  display: grid;
}
.ax-choices[hidden] {
  display: none;
}
.ax-choice {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
  min-height: 44px;
  box-sizing: border-box;
  padding: var(--shell-row-y, 0.5rem) 0.6rem;
  border: 2px solid transparent;
  border-radius: var(--ax-r);
  cursor: pointer;
}
.ax-choice[hidden] {
  display: none;
}
.ax-choice:hover {
  background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.ax-choice:has(input:checked) {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}
/* The ring goes on the ROW: the control is a native radio at the left edge and
   its own outline is easy to miss beside a three-line block. Same reasoning as
   `.ax-switch input:focus-visible + .ax-switch-track`. */
.ax-choice:has(input:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.ax-choice:has(input:disabled) {
  opacity: 0.45;
  cursor: not-allowed;
}
/* ⚠️ Aligned to the TITLE'S OWN first line, not by a magic offset. The app this
   came from used `margin-top: 0.2rem`, which is right at one font size and
   wrong at every other — and this family ships a text-size preference. `1lh`
   is the title's line box, so the control centres against it at any size. */
.ax-choice > input {
  flex: 0 0 auto;
  height: 1lh;
  margin: 0;
}
.ax-choice-b {
  min-width: 0;
  display: grid;
  gap: 0.15rem;
}
.ax-choice-t {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex-wrap: wrap;
  font-weight: 700;
}
.ax-choice-s {
  font-size: var(--ax-fs-sm);
  line-height: 1.5;
  color: var(--subdued);
}

/* ══ Tabs — switching SECTIONS inside one page ══════════════════════════════
   ⚠️ NOT the same component as the `.tabs` strip in cbprospect and cbrural,
   and do not "unify" them. Those are PAGE-level: sticky under the shell header,
   carrying the app's own gutter and tab height, scrolling horizontally, and
   switching the whole view. This one sits in the content flow and switches
   panes within a single screen — `/app/profile`'s Profile / Preferences /
   Security. One is chrome, the other is content; a component that tries to be
   both grows a `variant` prop and then a special case per app.

   The selected label is `--text`, not `--accent`: the underline carries the
   state and it only has to clear 3:1 as a UI component (WCAG 1.4.11), whereas
   an accent-coloured LABEL is small text and would have to clear 4.5:1 — which
   two of the family's nine accents do not. Colour is not the only signal
   either; `aria-selected` is what a screen reader reads. */
.ax-tabs {
  display: flex;
  gap: 0.25rem;
  margin: 1.25rem 0 0;
  border-bottom: var(--ax-line);
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
/* ⚠️ THE CUE IS A MASK, MEASURED — NOT A PAINTED SHADOW (cbapex#168). The strip
   used to carry Lea Verou's scroll-shadow: two `--surface` cover panels over two
   black shadows. Rendered at 390 it did the opposite of its comment on both
   grounds this family has. On the page ground the COVERS were the visible
   thing — two plum blocks at the strip's ends, present even with nothing that
   way — and on a card (where the cover matches) a 28% black shadow on a
   near-black surface is nothing at all: Forge's fifth tab read "Laun" with no
   sign of a sixth. A mask fades the strip's OWN pixels, so it is right on any
   ground; shell-controls.js measures which edge has more and says so in
   `data-scroll-more`, the same attribute the ledger's scroller wears. */
.ax-tabs[data-scroll-more="right"],
.ax-rolodex[data-scroll-more="right"] {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 3rem), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - 3rem), transparent);
}
.ax-tabs[data-scroll-more="left"],
.ax-rolodex[data-scroll-more="left"] {
  -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 3rem), transparent);
  mask-image: linear-gradient(to left, #000 calc(100% - 3rem), transparent);
}
.ax-tabs[data-scroll-more="both"],
.ax-rolodex[data-scroll-more="both"] {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 3rem, #000 calc(100% - 3rem), transparent);
  mask-image: linear-gradient(to right, transparent, #000 3rem, #000 calc(100% - 3rem), transparent);
}
.ax-tabs::-webkit-scrollbar { display: none; }
.ax-tab {
  appearance: none;
  border: 0;
  border-bottom: 2px solid transparent;
  background: none;
  color: var(--subdued);
  font: inherit;
  font-size: var(--ax-fs-sm);
  font-weight: 700;
  /* Vertical padding is the DENSITY token, the cbapex#16 pattern (operator,
     2026-09-07: "wire them to --shell-cell-y"). `--shell-cell-y` — the CELL
     step, 0.3 / 0.55 / 0.8rem — because a strip has several of these and the
     row step reads loose. The ±0.05rem keeps the underline's asymmetry and
     makes the default (0.55) exactly the 0.6 / 0.5 it replaced: the tab is
     36px at default, 28 compact, 44 comfortable. Every Profile screen in the
     family listed its three tabs as 82×36 tap-target candidates. */
  padding: calc(var(--shell-cell-y, 0.55rem) + 0.05rem) 0.9rem calc(var(--shell-cell-y, 0.55rem) - 0.05rem);
  cursor: pointer;
  white-space: nowrap;
  /* The underline lands where the border already is, so selecting a tab moves
     nothing — a strip that shifts by 2px on every click reads as a bug. */
  margin-bottom: -1px;
}
/* A thumb gets the 44px box. Not a transparent ::before band like the chrome's:
   `.ax-tabs` clips on the y-axis for its scroll-shadow, so a band past the tab
   would be clipped — and Assay's check, which reads the ::before, would report
   a target the thumb does not get. The tab itself grows, on a phone or any
   coarse pointer only; a mouse keeps the 36px strip. */
@media (max-width: 40rem), (pointer: coarse) {
  .ax-tab { min-height: 2.75rem; }
}
.ax-tab:hover { color: var(--text); }
/* A strip of LINKS between views (Flux's Mirror area, 2026-10-11) is the same
   strip: `<nav class="ax-tabs"><a class="ax-tab" aria-current="page">`. A
   link is not a tab, so it carries `aria-current`, never `aria-selected`
   (which ARIA allows only on tab, option and the like), and the underline
   follows either. */
a.ax-tab {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
}
.ax-tab[aria-selected="true"],
.ax-tab[aria-current="page"] {
  color: var(--text);
  border-bottom-color: var(--accent);
}
.ax-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* The pane a tab reveals. It authors NO display of its own, deliberately: the
   UA's `[hidden] { display: none }` is then enough, and there is nothing for a
   `hidden` attribute to lose to. The rule below is belt-and-braces for the same
   trap the section under this one exists to close. */
.ax-tabpane[hidden] { display: none; }

/* ══ hidden wins ═══════════════════════════════════════════════════════════
   ⚠️ THE FAMILY TRAP, GUARDED ONCE INSTEAD OF FIFTY-THREE TIMES.
   `hidden` is an HTML attribute whose whole meaning is "not relevant", and its
   `display: none` comes from the UA stylesheet — so ANY author rule that sets a
   display beats it, at any specificity. This file authors 53 of them. It has
   cost this family a tab strip already, and nearly cost a false bug report; the
   standing rule is "write the [hidden] rule next to the display rule", and one
   rule out of 53 (`.ax-btn`) actually did.

   Adopting `.ax-deck` across sixteen app grids (cbapex#22) is what made that
   arithmetic urgent: every element gaining `.ax-deck` gains a `display: grid`
   it did not have, so a deck the app hides with `el.hidden = true` would have
   silently stopped hiding. Guarding each component is the drift-prone version
   of this; guarding the NAMESPACE is one rule.

   Specificity, since it is the whole point: two attribute selectors is (0,2,0),
   which beats every selector in this file — `.ax-deck` at (0,1,0) and
   `.ax-srclist a` at (0,1,1) alike — without `!important`, so an app can still
   override deliberately in its own sheet. The descendant pair covers a plain
   `<a hidden>` inside an ax- container, whose display comes from the container's
   rule rather than its own class.

   Scoped to the ax- namespace on purpose: this file is answerable for what it
   styles, not for every `hidden` on the page. server/components-hidden.test.ts
   asserts the coverage still holds as components are added. */
[class^="ax-"][hidden],
[class*=" ax-"][hidden],
[class^="ax-"] [hidden],
[class*=" ax-"] [hidden] {
  display: none;
}
