/* THE PATTERN LIBRARY'S STYLESHEET.
   Loaded by layout.njk on every page, immediately after ops.css.

   WHAT IS IN HERE, AND WHAT IS NOT. Almost every rule below was MOVED out of
   ops.css, scheduling.css or time-off.css unchanged — same declarations, same
   order, same tokens. Nothing was redesigned. This wave builds the components
   and proves adoption changes no rendered byte; the design decisions Ted has
   yet to make (the chrome, the type pairing, control height, density) land
   centrally, once, after the pattern sheet. Deleting what is left of ops.css
   is wave 3.

   WHY MOVING IS SAFE. This sheet loads after ops.css and before every
   per-page sheet, which are linked in the body. So a rule moved here can only
   change what renders if some LATER rule in ops.css, at equal or lower
   specificity, sets an overlapping property on the same elements. Every
   selector below was checked for that:

     .eyebrow .lede .hero .hero-actions   appear nowhere else in any sheet
     .kpi*                                later rules are .dash-kpi-host.kpi*
                                          (0,2,0) and one !important — both win
                                          on specificity regardless of order
     .table-scroll                        the only other rule is
                                          .print-document .table-scroll (0,2,0)
     .kv-* / .k / .v                      later rules are .meta-card.overdue .k
                                          (0,3,0), .sched-reading .k and
                                          .to-balance .v — all higher, and the
                                          last two load after this file anyway

   THE ONE THING HERE THAT IS NOT A MOVE is `.panel-head` / `.panel-meta`, at
   the foot. Nothing in the tree carries either class today, so — by the same
   argument css-debt-audit.md Claim 3 makes for `.panel.warning` — the blast
   radius is provably nil: no surface can change because no surface matches.
   They exist because the panel component promises a right-hand meta slot and
   a promise with no rule behind it renders as a stacked div. No new colour;
   `tests/test_design_tokens.py` counts this file's raw hex from today.

   WHAT WAS DELIBERATELY NOT MOVED, and why, so the next wave does not have to
   rediscover it:

     .panel .pill .btn .ops-info-* — the components use them and they stay in
     ops.css this wave. Each has later rules in the same sheet that would need
     the same specificity audit as the four above, and doing thirty of those
     in the wave that also introduces the components is how a "no rendered
     byte changed" claim stops being true. Wave 3. (Control height, `.btn.tiny`
     and `.btn.quiet` are the named exceptions below — Ted ruled on them, so
     they are not waiting on that audit.)

   WHAT LANDED THIS WAVE AS NEW RULES, NOT MOVES — the six things RULING
   PENDING in wave 0/1 (SKILL.md §7, css-debt-audit.md Claims 3 and 4, and
   the four macro comments) that Ted ruled on the evening of 2026-09-08:
   control height, `.btn.tiny`, `.btn.quiet`, `.panel.ok`/`.panel.danger`,
   `li.unread` outside the inbox, and `.num`. Each is a genuinely NEW rule —
   nothing here was moved unchanged — so each is exactly the audit the
   "WHY MOVING IS SAFE" paragraph above skips: read its own comment for what
   it does and does not change. */

/* --- control height -------------------------------------------------------
   Ted's ruling, 2026-09-08: desktop controls are 36px tall; the 44px tap
   floor applies at 959px and below (the number SKILL.md §7 already used to
   name the mobile floor — this is the first time anything names the desktop
   number). `.btn`, `input`, `select` and `textarea` are "a control" for this
   rule; checkboxes and radios are not — they are a tap target of their own
   shape, styled per form (`.ops-check input`, ops.css:457), not a box with a
   height to agree on.

   MIN-HEIGHT, NOT HEIGHT, and padding/font-size small enough that it is the
   number actually rendered rather than a floor under something taller: a
   fixed `height` would clip a `.btn` whose label wraps, and would flatten
   every un-scoped `<textarea rows="N">` in the tree (18 of them, none with a
   page-scoped height already) to one line. A page's own taller override
   still wins where it should — `.ops-info-form textarea { min-height: 5rem }`
   (0,0,1,1) beats this rule's bare `textarea` (0,0,0,1) on SPECIFICITY, the
   one and only reason two competing `min-height` declarations ever pick a
   winner; nothing in CSS takes "the greater value" of two declarations on
   the same property; the higher-specificity one simply wins outright,
   whatever it says.

   `input:not([type="checkbox"], [type="radio"])` — ONE `:not()` with a
   selector LIST, not `:not(a):not(b)`. Two separate `:not()`s each add
   their own specificity (elements = 1, "classes" = 2), which measured
   (0,0,2,1) — MORE specific than a typical two-class scoped rule like
   `.scan-field .scan-input` (0,0,2,0), and silently overrode its font-size
   at every width (found live via getComputedStyle, 2026-09-08: 16px where
   1.25rem was written). The list form's specificity is that of its single
   most specific argument, so this is (0,0,1,1) — the same weight as one
   class, which is what "not a checkbox or radio" should cost.

   `<a class="btn">` IS THE REASON THIS NEEDS `display`, not just sizing.
   css-debt-audit.md Claim 4 measured it: an anchor is `display: inline` by
   default, and `min-height` has no effect on a non-replaced inline box — so
   `<a class="btn tiny">Open in WaterLink</a>` rendered 32.4px on a phone
   before this rule existed, already under the floor, regardless of what
   min-height said. `inline-flex` fixes it for both the anchor and the
   button in one declaration and centres the label inside the new height
   without a line-height computation to get wrong. */
.btn,
input:not([type="checkbox"], [type="radio"]),
select,
textarea {
  min-height: 36px;
  padding: 0.35rem 0.75rem;
  font-size: 0.875rem;
  line-height: 1.2;
}
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
/* AND `hidden` HAS TO WIN OVER IT. The line above is a class selector
   (0,1,0) and the browser's own `[hidden] { display: none }` is an attribute
   one (0,1,0) declared earlier, so a `.btn` carrying `hidden` RENDERS — the
   torch toggle in the camera scanner and the Scan button on a machine with
   no camera both showed themselves, and both are supposed to be invisible
   until a capability answers. `expenses.css` already carries eight copies of
   this fix for its own boxes; this is the one every `.btn` in the product
   needs, written once. `!important` because a page sheet's own `.thing .btn`
   (0,2,0) would otherwise outrank a plain `.btn[hidden]` (0,2,0) by order —
   the same reasoning expenses.css:18 spells out. */
.btn[hidden] { display: none !important; }
/* --- the control SKIN -----------------------------------------------------
   THE SAME RULE, ONE DECLARATION BLOCK LOWER, and it exists because the
   height reached controls the LOOK never did. `input`, bare, is what the
   block above says; but every page that drew the border, the radius and the
   font wrote an ENUMERATED TYPE LIST instead — `.rule-form input[type="text"],
   input[type="number"], select` (ops.css:1582), `.inline-form
   input[type="text"]` (:2537), `.expenses` naming six types, `.time-off`
   three. A type not on the list fell through to the browser's own chrome.

   MEASURED BEFORE THIS RULE, at 1366, across the Setup door: 121 controls
   rendering in Arial with a 2px inset grey border and no radius, beside
   skinned ones on the same form. `/admin/config?tab=expenses` 67 of its 68,
   `/admin/users` 36 (EVERY email and password field), `/account` 3,
   `/admin/users?tab=sso` 2 (the two `url` fields), `/admin/config?tab=manual`
   3 (a `search` box and two bare `<input>`s), and so on. `expenses.css:27`
   even carries a comment saying what happens — "left out of this list renders
   as an unstyled browser default sitting next to a styled one" — which is the
   defect described and then patched by enumeration on one door.

   SO THE SELECTOR IS THE SAME NEGATIVE ONE THE HEIGHT USES, for the reason
   the block above gives at length: a control is everything that is not a
   checkbox, a radio, a hidden field or a range slider. `hidden` and `range`
   join the list here and not above, because a hidden input has no box to
   give a height to but WOULD take a border if one were offered, and a range
   slider is a track and a thumb that a 1px border and a radius disfigure.

   `font-family: inherit` rather than `font: inherit`: the shorthand resets
   `font-size` and `line-height`, which the height rule above sets on purpose
   and which the phone block re-sets to 1rem for the iOS zoom threshold.

   PER-PAGE ENUMERATIONS ARE DELETED WHERE THIS COVERS THEM (ops.css's
   `.rule-form` and `.inline-form`, expenses.css, time-off.css,
   scheduling.css's two): what those rules kept is LAYOUT — `display: block`,
   `width: 100%`, `margin-top` — which is a form's business and not a skin's.
   Anything they still set that differs from this is a deliberate override and
   says so where it stands. */
/* BUTTONS TOO, BUT ONLY THE FAMILY (walk 2026-10-02, Bug 33). The browser's
   own sheet gives `<button>` a font of its own, so every button rendered in
   Arial beside Plex inputs. Not added to the skin rule below, which would
   repaint every button's colour, fill and border; the family is all that was
   missing. Pinned in e2e/chrome_ink.spec.js. */
button,
.btn {
  font-family: inherit;
}
input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"]),
select,
textarea {
  font-family: inherit;
  color: var(--ink);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 6px;
  box-sizing: border-box;
}

@media (max-width: 959px) {
  .btn,
  input:not([type="checkbox"], [type="radio"]),
  select,
  textarea {
    min-height: 44px;
    padding: 0.5rem 0.9rem;
    /* 1rem, not 0.875rem: 16px is the threshold below which iOS Safari
       zooms the whole page on focus. A control that is finally a real tap
       target should not then make the reader pinch back out to use it. */
    font-size: 1rem;
  }
  /* A short label ("Add", "Fold") made a button 44px tall but 40px wide —
     taller than it is wide reads as a stub, not a target (papercut,
     2026-09-08: /expenses/vendors' Add measured 44×40 next to a full-width
     Clear). Width, not input/select/textarea — a text box already fills
     its row and a min-width would only fight that. */
  .btn { min-width: 44px; }
}

/* --- the checkbox row ------------------------------------------------------
   48px ON A PHONE, which is SKILL.md §7's own number — "48px on mobile for a
   checkbox/radio row, deliberately different for wet gloves" — and which
   almost nothing in the product was.

   MEASURED at 390 before this rule: /admin/time-off?tab=types' "Paid" and
   "Tracks a balance" 326x19, /admin/users?tab=sso's "Enabled (show on login
   page)" 326x20, /admin/notifications' "Enabled" 292x20, /admin/ideas' "Needs
   author review" 20px. The LABEL is the target for the box inside it, so a
   19px row is a 19px target — the input itself is 13px in every browser and
   always has been, which is why measuring the input tells you nothing.

   `:has(> input)` RATHER THAN A CLASS LIST, and that is the whole point of
   moving it here. The rule this replaces named `label.check` and
   `.users-check-row`, the two shapes somebody had measured; the two it did
   not name were found by the next review. A floor written as an enumeration
   is the same defect the control skin above had, one review apart. Direct
   child only: a label that CONTAINS a checkbox three levels down is a
   fieldset's legend, not a row.

   THE RECIPIENT PICKER IS THE ONE EXCEPTION AND IT STANDS. `.notify-check-light`
   is one row per person in a list of fifteen on /admin/notifications; 48px
   each makes that picker three screens long. A list is not an option, and
   which of the two a control is cannot be read off the markup — so it is
   named here, once, rather than decided per page. */
/* LIFTED OFF `.ops-info-form` (design review, Round 6, M1). The geometry below
   used to be written twice: a bare `label:has(> input)` here that only gave a
   48px ROW at <=959, and a `.ops-info-form`-prefixed block that also gave the
   BOX a size and the row a desktop floor. So which of the two a checkbox got
   depended on whether the form around it happened to carry a class — and the
   forms that did not are not exotic: measured at 1366, the Reimbursable row on
   a filed expense was 19px tall with a 13x13 box, and the self-approval switch
   on Expense settings 13px, sitting beside dialog checkboxes that were 20px
   because THEIR form was an `.ops-info-form`. Two sizes of the same control,
   one screen apart, decided by a class nobody chose for that reason.

   A floor written as a class prefix is the same defect as a floor written as
   an enumeration — the shape the rule above this one already records, one
   review apart. There is one rule now, and it is the control's, not the
   form's. */
/* TWO SELECTORS, ONE RULE, AND THE SECOND ONE WEIGHS NOTHING. That is the
   boundary, and it is the whole of the seam review's major finding.

   THE COLLATERAL, MEASURED. Written as a bare `label:has(> input)` this is
   (0,1,2) — `:has()` takes its argument's specificity — which beats any page
   rule that names ONE class. There are fifteen of those in the tree, and
   `/admin/capabilities` is the one that shows what it costs: `.caps-atom`
   is `display: grid` at (0,1,0), so all 638 atoms flipped to flex, the code,
   the blurb and the floor line fell from x=28 (grid column 2, the hanging
   indent) to x=0 under the box, rows grew 136 -> 160px, and
   `grid-row: 1 / -1` / `grid-column: 2` became dead declarations.
   `.sched-tech-chip` went 29 -> 44px and pushed the board down 105px.
   `.sched-wd` and `.tech-focus-toggle` are (0,1,0) too and were next.

   A LABEL THAT LAYS OUT ITS OWN CHILDREN IS NOT A CHECK ROW. The census is
   101 `<label>` elements wrapping a checkbox or radio across `web/views`, in
   30 class shapes, and it does not divide by page or by door — it divides by
   whether somebody DESIGNED that label as a row of [box][words] or as a grid,
   a chip or a filter strip that happens to contain a box.

   SO THE RULE SAYS IT TWICE, at two weights, and never as a list of
   exceptions — the shape this file already refuses one rule above, and the
   census proves an enumeration would be fifteen names and growing:

     1. THE SHAPE SOMEBODY DESIGNED. `label.check` and `label.field-check` —
        what `field({ check })` emits and what the house writes by hand. The
        `:has()` is carried for WEIGHT, not for matching: it takes these to
        (0,2,2), which is what beats a page-wide `.expenses label
        { display: grid }` at (0,1,1) in a sheet that loads after this one.
        Without it the Reimbursable row is a grid again.
     2. EVERY OTHER LABEL THAT HAPPENS TO WRAP A BOX, through `:where()`,
        which contributes ZERO. So this half is a DEFAULT: it still reaches
        the 19 unclassed labels and `/alerts`'s auto-run box — the wave-4
        finding that a floor written as an enumeration misses the ones nobody
        measured — and it steps aside for `.caps-atom`, `.sched-tech-chip`
        and every future label that declares a display of its own, with no
        list to maintain and nothing to remember.

   `web/e2e/checkbox_geometry.spec.js` pins the boundary as COMPUTED STYLE at
   both widths, because this slipped past a test that read the stylesheet as
   text. */
label.check:has(> input[type="checkbox"], > input[type="radio"]),
label.field-check:has(> input[type="checkbox"], > input[type="radio"]) {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}
/* THE DEFAULT SAYS `column-gap`, NOT `gap`, AND THAT IS NOT A DETAIL. `gap` is
   a shorthand for BOTH axes, so at zero specificity it still set a `row-gap`
   on labels that had only ever declared a `column-gap` of their own — and a
   shorthand's other half is the one property a page author cannot be expected
   to have overridden, because they never wrote it. MEASURED on
   `/admin/capabilities`: `.caps-atom` declares `column-gap: 0.5rem` and its
   computed `row-gap` went `normal` -> 8px, which is 8px between every one of
   an atom's three text rows — 137px rows to 165px, times 638 atoms. The space
   between a box and its words is a COLUMN gap; the row gap only matters when a
   hint wraps, and that only happens on the two designed shapes above. */
label:where(:has(> input[type="checkbox"], > input[type="radio"])) {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  column-gap: 0.5rem;
}
/* 44 IS A DENSITY DECISION AND 48 IS A SAFETY FLOOR, and that is why only one
   of them reaches every label. The skill says both in its own words: 44px at
   <=959 is "a control HEIGHT now, not only a tap floor", and 48px on a phone
   is "deliberately different for wet gloves".
     * A ROW SOMEBODY DESIGNED gets 44 on a desk, because that is a decision
       about how dense a form is, and a form is the thing that was designed.
     * A CHIP OR A GRID THAT HAPPENS TO HOLD A BOX does not. `.sched-tech-chip`
       is 29px by choice; the desktop floor took it to 44 and pushed the
       scheduling board down 105px, which is the seam review's own measurement
       and the reason this is two rules and not one line.
     * BELOW THE TAP BREAKPOINT EVERY BOX GETS 48, chips included, and that is
       wave 4's finding standing exactly where it stood before this round: a
       floor written as an enumeration missed the ones nobody had measured. A
       gloved thumb does not know which labels a designer thought about. */
label.check:has(> input[type="checkbox"], > input[type="radio"]),
label.field-check:has(> input[type="checkbox"], > input[type="radio"]) {
  min-height: 44px;
}
/* A hint under a checkbox row wraps to its own line rather than being squeezed
   in beside the words. Same two weights, same reason. */
label.check:has(> input[type="checkbox"], > input[type="radio"]) > .sub,
label.field-check:has(> input[type="checkbox"], > input[type="radio"]) > .sub,
label:where(:has(> input[type="checkbox"], > input[type="radio"])) > .sub {
  flex-basis: 100%;
}
/* THE BOX ITSELF IS NOT A LAYOUT DECISION, so it keeps one weight and reaches
   every checkbox in the product — including the ones inside a `.caps-atom`
   grid, which want a 20px box and their own placement. */
label > input[type="checkbox"],
label > input[type="radio"] {
  width: 1.25rem;
  height: 1.25rem;
  margin: 0;
  /* NEVER SHRUNK BY THE WORDS BESIDE IT. The row is a flex container, so a box
     with a width and the default `flex-shrink: 1` is a box the first long
     label squeezes — measured on /inventory's filters at 1366, 13 wide against
     20 tall. `ops.css` had this for the forms it happened to class; it belongs
     to the control. */
  flex: 0 0 auto;
}
@media (max-width: 959px) {
  /* …and here the `:where()` half carries the floor too, which is the
     asymmetry the block above explains: 48 is for the thumb, not for the
     design. */
  label.check:has(> input[type="checkbox"], > input[type="radio"]),
  label.field-check:has(> input[type="checkbox"], > input[type="radio"]),
  label:where(:has(> input[type="checkbox"], > input[type="radio"])) { min-height: 48px; }
  label > input[type="checkbox"],
  label > input[type="radio"] { width: 1.5rem; height: 1.5rem; }
}
/* THE RECIPIENT PICKER IS STILL THE ONE EXCEPTION, and lifting the rule gave
   it THREE things to opt out of rather than one. The floor is the obvious one:
   fifteen people at 44px each is a picker three screens long, and a list is
   not an option.

   THE OTHER TWO WERE FOUND BY MEASURING AFTER, not by reading. A picker row is
   a name and an email, and the email is a `.sub` — so `flex-wrap: wrap` plus
   `> .sub { flex-basis: 100% }` put every address on its own line and took the
   rows to 58px at 1366, which is WORSE than the floor this exception exists to
   escape. The address belongs beside the name here; the wrap is right for a
   consent row with a sentence under it, which is what it was written for.
   Named here with the floor, once, rather than decided per page. */
/* THE RECIPIENT PICKER IS NO LONGER AN EXCEPTION IN THE OLD SENSE, and that is
   the second thing the `:where()` half buys. Fifteen people at 44px each is a
   picker three screens long, so it escapes the floor — but it escapes it the
   way every other page rule now does, by being a class: `.notify-check-light`
   is (0,1,1) against the default's (0,0,1) and simply wins.

   IT USED TO LOSE. `label.notify-check-light` (0,1,1) against a bare
   `label:has(> input[type="checkbox"])` (0,1,2) was a documented exception
   that had never applied — measured at 48px at 390, exactly the floor it
   exists to escape — which is why it briefly carried a `:has()` of its own.
   It does not need one now, and the `:has()` is gone with the arms race that
   produced it.

   The two declarations besides the floor are the picker's own shape: a name
   and an address on ONE line. `flex-wrap: wrap` plus `> .sub
   { flex-basis: 100% }` is right for a consent row with a sentence under it
   and wrong for this, and it took the rows to 58px at 1366 when the geometry
   was first unscoped. */
label.notify-check-light {
  min-height: 0;
  flex-wrap: nowrap;
}
label.notify-check-light > .sub { flex-basis: auto; }

/* --- field({ check: true }): the checkbox row's own shape -------------------
   TWO DECLARATIONS, AND THAT IS THE FINDING. The box goes to the LEFT of its
   words; `field` writes the label text first — the majority instance it was
   lifted from, and the order a screen reader wants — so the reversal is
   visual and the DOM order is untouched.

   EVERYTHING ELSE A CHECKBOX ROW NEEDS ALREADY EXISTS, generically, and the
   page-private class was carrying a copy of some of it. Measured in Chrome on
   /inventory at 1366 and 390, the rules that actually win on this label are
   `ops.css`'s own:

     .ops-info-form label:has(> input[type="checkbox"])
       display: flex; flex-wrap: wrap; align-items: center;
       gap: 0.5rem; min-height: 44px          (48px at <=959)
     .ops-info-form label > input[type="checkbox"]
       width: 1.25rem; height: 1.25rem; margin: 0   (1.5rem at <=959)

   — (0,2,1) and (0,2,2), both above anything a single class here can say. So
   a `.field-check` that re-declared `display: flex`, a gap, a min-height and a
   box size would be four declarations that lose, sitting in the file a reader
   goes to first. The two below are the two that are genuinely this option's.

   WHAT MOVING IT BUYS, since the geometry did not change: the rule is where
   every page can reach it, and it arrives through the component rather than
   through `classes:`. `.check-field` in `ops.css` was a component's job done
   in one door's stylesheet — the shape the census was measuring — and the
   next page wanting a checkbox in a form grid had no way to find it. */
.field-check {
  flex-direction: row-reverse;
  justify-content: flex-end;
}

/* .btn.tiny — THE compact button size, defined once (ruling, 2026-09-08).
   `scheduling.css` carried four copies of this, resolving only on the five
   pages that load it; 22 templates wrote `btn tiny` and got a full-size
   button, including layout.njk's inbox "Mark all read" on every page in the
   product (css-debt-audit.md Claim 4).

   28px, NOT 0 — a real floor, not "no floor of this rule". The first cut
   here read `min-height: 0` as "let padding/font decide", which computed
   23.3px: under the 24×24 WCAG 2.5.8 AA minimum SKILL.md itself quotes,
   caught by the design review 2026-09-08. 28px is the number that still
   reads compact next to a 36px sibling at 0.78rem type, and clears the
   floor with room to spare.

   NO SEPARATE MOBILE PADDING: a compact button on a phone is still a
   target, so only the 44px floor changes below — the small font and
   padding stay small, and the extra height is inside the box, not visible
   as bigger text. */
.btn.tiny {
  min-height: 28px;
  padding: 0.2rem 0.5rem;
  font-size: 0.78rem;
}
@media (max-width: 959px) {
  .btn.tiny { min-height: 44px; }
}

/* .btn.quiet — a button with no border and no fill, for an action that
   should read as present but not compete with the primary in its region
   (ruling, 2026-09-08; 25 call sites across 13 templates already write
   `class="btn quiet"` and got a plain secondary button, per the button
   macro's own comment). `border-color: transparent`, not `border: none` —
   the border's 1px is still there taking up space, so a quiet button is
   the same height as the primary and secondary buttons beside it; dropping
   the property entirely would pull it 2px shorter than its siblings.

   `--ink` AT REST, NOT `--muted` (design review, 2026-09-08): the first
   cut used `--muted` for the resting text, and a button with no border, no
   fill AND muted text reads as disabled — which is a real, different state
   `_config_gear.njk`'s "always labelled, never disabled" rule already
   argues against implying by accident. `--muted` stays reserved for an
   actually-disabled `.btn` (`.btn:disabled`, ops.css). Hover comes from
   tokens (`--surface-sunk`, already the inbox's own hover fill) rather
   than a new literal. */
.btn.quiet {
  border-color: transparent;
  background: transparent;
  color: var(--ink);
}
.btn.quiet:hover {
  background: var(--surface-sunk);
}

/* --- pageHeader ---------------------------------------------------------- */
/* Moved from ops.css:768, :781, :783, :3577. */
.eyebrow {
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 0.72rem;
  color: var(--muted);
  font-weight: 600;
}
/* A HEADING'S COLOUR IS THE HEADING'S (screenshot critique, 2026-09-09,
   entry 6 / "rules the skill is missing" §6): `/my-day`, `/crm/accounts/<id>`
   and `/crm/jobs/<id>` render an eyebrow or a panel h2 in the accent because
   it happens to wrap an <a> — a heading and a plain-text one three rows
   away read as two different colours for the same element on one screen.

   DESCENDANT, NOT `.panel > h2`, which is what the finding named: the `panel`
   macro wraps the heading in `.panel-head` whenever `metaHtml` is set, and a
   heading three levels down from `.panel` still owns its own colour. The same
   reason covers `.panel-head > h2 a`, which `service.css` used to list
   separately.

   EVERY LEVEL, not just h2 (lifted from `service.css`, wave 3): `chartFrame`
   defaults to `h3` and takes `h2`, `panel` takes either, and a hero's title is
   an `h1` — a rule that named one of the three would have left the other two
   teal on the pages that happen to use them.

   `text-decoration: none` with it, and the underline back on HOVER: a heading
   that is also a link should not carry an underline it never had — the whole
   heading is the affordance — but a reader still needs to find out it is one. */
.hero h1 a,
.panel h1 a,
.panel h2 a,
.panel h3 a,
.eyebrow a { color: inherit; text-decoration: none; }
.hero h1 a:hover,
.panel h1 a:hover,
.panel h2 a:hover,
.panel h3 a:hover,
.eyebrow a:hover { text-decoration: underline; }

/* AND A LINKED TILE VALUE IS NOT A LINK EITHER. `tile({ href })` wraps the
   number in an <a>, so a drillable KPI rendered its figure in the accent teal
   while the plain KPIs beside it stayed indigo — the same defect as the
   heading above, on the one element in the product whose whole job is to be
   compared with its neighbours. `--muted` for the label, the page's ink for
   the number, whether or not it goes anywhere. */
.kpi .value a { color: inherit; }
.lede { color: var(--muted); margin: 0 0 1.25rem; max-width: 52rem; }

/* AN EYEBROW LINK IS 15px TALL, and on a phone it is the way back. Measured at
   390 before this rule: `/crm/jobs/<id>`'s "Jobs" was 33x15 and
   `/crm/accounts/<id>`'s "Accounts" 67x15 — under even WCAG 2.5.8 AA's 24x24,
   let alone the 44px floor SKILL.md §7 sets for this product at <=959px, on
   the one control every detail page has.

   AN INVISIBLE PSEUDO-ELEMENT, NOT PADDING, and the precedent is `.chip-x`
   (ops.css) which made the same trade for the same reason: the hero's vertical
   budget on a phone is real, and giving the anchor a 44px BOX would push every
   detail page's title down ~29px to buy a target the reader cannot see anyway.
   Centred, `max(100%, 44px)` wide so a 33px label still clears the floor in
   both directions, and phone-only — a mouse does not need it.

   `inline-block` so `width: 100%` on the absolutely-positioned child resolves
   against a box with one deterministic width, rather than against whichever
   fragments an inline anchor happened to break into. */
@media (max-width: 959px) {
  .eyebrow a {
    position: relative;
    display: inline-block;
  }
  .eyebrow a::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: max(100%, 44px);
    height: 44px;
  }
}

/* NOWRAP on a desktop, and the title/lede column — not the actions —
   absorbs the shrink (screenshot critique, 2026-09-09, entry 9): `.lede`'s
   own 52rem max-width made `.hero` wrap on any page with a long lede
   (/crm/quotes, /crm/insights/revenue and others), dropping the gear and
   the primary action to a left-aligned row under the title — a different
   corner page by page, on the one element meant to be in the same place
   everywhere. `min-width: 0` on the title/lede column is the fix flexbox
   actually needs: a flex item's automatic minimum size is its CONTENT size
   unless told otherwise, so without it `nowrap` would have swapped "wraps
   to a second row" for "overflows sideways" — a worse bug, not a fix. */
.hero {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  align-items: flex-start;
  flex-wrap: nowrap;
  margin-bottom: 1.25rem;
}
.hero > :first-child { min-width: 0; }
/* The gear sits first in the actions column; right of the title, left of the
   primary action (ruling N3, 2026-09-05). `margin-left: auto` keeps it
   pinned to the right edge even if a third `.hero` child ever arrives —
   `justify-content: space-between` above only promises that for exactly
   two. */
.hero-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-left: auto; }
/* Below the rail's own breakpoint, wrap is the rule, not the bug: there is
   no room for a title and a button side by side, so the actions stack
   under the title — the one shape `.hero` had everywhere before this fix,
   kept on purpose at phone and small-tablet width. */
@media (max-width: 959px) {
  .hero { flex-wrap: wrap; }
}

/* --- tile ---------------------------------------------------------------- */
/* Moved from ops.css:804-825. Five across, two on a phone — and five is the
   ceiling: the executive page once had eleven at identical weight and a tag
   count read as loudly as the number the business is judged on. */
.kpi-grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 0.85rem;
  margin-bottom: 1.25rem;
}
@media (max-width: 1000px) {
  .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.kpi {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 1rem;
  box-shadow: var(--shadow);
}
.kpi .label { font-size: 0.72rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
/* The figure carries the client's brand (D8, 2026-10-02); body text does not. */
.kpi .value { font-size: 1.8rem; font-weight: 700; margin: 0.25rem 0; color: var(--brand-primary); }
.kpi .delta { font-size: 0.85rem; color: var(--muted); }
/* --ideal/--emergency swapped for --good-ink/--danger-ink (design review,
   2026-09-08): a tile's trend is a general up-or-down judgement, not a
   water-chemistry reading, and --ideal/--emergency are the tokens
   SKILL.md reserves for that axis alone. It was also the wrong contrast by
   accident, not just the wrong axis — --ideal measures 3.45:1 on white,
   under AA; --good-ink (already .panel.ok's own token) measures 6.45:1.
   --danger-ink is the same ink `.panel.danger`, `.btn.danger` and
   `.form-status.error` already use for "this is bad" outside chemistry.

   COLOUR NO LONGER INFERRED FROM .up/.down (screenshot critique, 2026-09-09,
   "rules the skill is missing" §7): `deltaDirection` is sign — which way the
   number moved — and `deltaPolarity` is the judgement about whether that is
   good news, and the `tile` macro's own contract ("only the caller knows
   which way is good") was violated by this file the moment .up/.down carried
   colour, because a bad-when-up number then had to pass a false direction to
   get the right colour — `lib/pulse_view.js`'s delta() docstring said so
   outright. A delta with a direction and no polarity now renders in --muted,
   same as one with neither: silence rather than a guessed colour. */
.kpi .delta.good { color: var(--good-ink); }
.kpi .delta.bad { color: var(--danger-ink); }
.kpi-grid:empty { display: none; }

/* --- table --------------------------------------------------------------- */
/* Moved from ops.css:2622. */
.table-scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  max-width: 100%;
  /* POSITIONED SO THE SCROLLER ACTUALLY CONTAINS WHAT IT SCROLLS.
     `overflow-x: auto` clips normal-flow children, but an ABSOLUTELY
     positioned descendant is only clipped by an ancestor that is in its
     containing-block chain — and nothing between a `.visually-hidden` span and
     the page was positioned. Those spans are how a data table stays readable
     to a screen reader ("No expiry date" where a sighted user sees an em
     dash), so they sit in cells that scroll 190px off to the right, and they
     escaped the scroller and dragged the whole document with them.
     Measured on the draft quote page at 375px: documentElement.scrollWidth was
     513 against a 375 viewport, so the page scrolled sideways 138px while
     `body.scrollWidth` stayed a blameless 375 — which is why the scroller
     looked innocent (clientWidth 311, scrollWidth 501, doing its job).
     One word fixes it for every wide table in the product. */
  position: relative;
}

/* `.num` — a new rule, not a move (ruling, 2026-09-08). The table macro has
   written `class="num"` on every numeric th/td since wave 0, but the class
   resolved only under `#expense-approvals table.data` (expenses.css, now
   deleted) and `.to-grid` (time-off.css, now deleted) — everywhere else a
   numeric column sat left-aligned with proportional figures, the exact
   defect the macro's own comment named. One rule, everywhere the markup
   already asks for it.

   `table.data th.num, table.data td.num`, NOT bare `th.num, td.num` —
   THE FIRST VERSION OF THIS RULE SHIPPED AND NEVER FIRED. `ops.css`'s
   `table.data th, table.data td { text-align: left }` is one class plus
   two elements, (0,0,1,2); a bare `th.num` is one class plus one element,
   (0,0,1,1) — lower, so `text-align: left` won on every `table.data` in
   the tree regardless of load order, and the fixture named "numeric
   columns, which are right-aligned" rendered left-aligned (caught by a
   computed-style check, 2026-09-08 evening; a visual read of a screenshot
   at this column width missed it — measure, do not eyeball, alignment).
   Matching `table.data`'s own two-element shape keeps this specific
   without matching everything: (0,0,2,2) beats (0,0,1,2) the same way
   `#expense-approvals table.data th.num` (0,1,2,2) used to, on ID alone. */
/* --- table({ stickyFirst: true }) ------------------------------------------
   THE ROW'S NAME STAYS ON SCREEN. Opt-in, on the shared table rules rather
   than in one page's stylesheet, because "a wide table loses the column that
   says which row you are reading" is not a property of /inventory.

   MEASURED on /inventory, documentElement.clientWidth asserted:

     390   .table-scroll 326px over a 2029px table; 3 columns on screen.
           Scrolled to the end (scrollLeft 1703), the Item cell's left edge
           was at x=-1671 — the name is a metre off the side of the phone
           while you read the number you scrolled to.
     1366  1041px over 1743px; the same cell at x=-419.

   `left: 0` ON THE FIRST `th` AND `td`, with the panel's own background under
   it — a sticky cell is still transparent, and thirteen columns slide under a
   transparent one. A hairline down its right edge so the pinned column reads
   as a pinned column and not as a rendering fault.

   AND THE HEADER, which needs a scroller with a HEIGHT to stick inside.
   `.table-scroll` is `overflow-x: auto`; CSS computes the other axis to `auto`
   whenever one axis is not `visible`, so the wrapper is a scroll container in
   BOTH directions — a `position: sticky; top: 0` thead inside it has nothing
   to stick to while the wrapper is as tall as its content, and the whole
   header scrolls off with the page. `max-height: 70vh` is what makes it real:
   538px on a 768 screen, 591px on an 844 one, and no effect at all on a table
   shorter than that. Only on a sticky-first scroller — every other
   `.table-scroll` in the product is untouched.

   Z-ORDER, three layers and each one earns it: body cells 0, the pinned first
   column 1 (over the cells sliding under it), the header row 2 (over the rows
   scrolling under it), and the header's own first cell 3, because it is the
   one place both stacks meet. */
.table-scroll.sticky-scroll { max-height: 70vh; }
.data.sticky-first thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--panel);
}
.data.sticky-first th:first-child,
.data.sticky-first td:first-child {
  position: sticky;
  left: 0;
  z-index: 1;
  background: var(--panel);
  border-right: 1px solid var(--line);
}
.data.sticky-first thead th:first-child { z-index: 3; }

/* --- table({ stickyHeader: true }) ------------------------------------------
   THE HEADER ROW ALONE, for a table whose first column is not a key worth
   pinning (Ted's ruling 12, 2026-09-18). The same `max-height: 70vh` on the
   scroller does the work here that it does for `sticky-first`, and for the
   reason written out above it: `.table-scroll` is `overflow-x: auto`, CSS
   computes the other axis to `auto` whenever one is not `visible`, so the
   wrapper is a scroll container in both directions — and a `top: 0` thead has
   nothing to stick to while that wrapper is as tall as its content.

   ONE LAYER, not three. There is no pinned column here, so the only stack is
   "header over the rows scrolling under it". The panel's own background under
   it, because a sticky cell is still transparent. */
.data.sticky-head thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--panel);
  /* THE EDGE. A collapsed table paints the header's bottom border as the
     table's, and the table scrolls away under the stuck row — at 390 a
     56px row climbed 32px into the header band (design seat, Round A). An
     inset shadow is the hairline the row keeps. */
  box-shadow: inset 0 -1px 0 var(--line);
}

/* THE HEADER LINK IS A HEADER, NOT A LINK (SKILL.md: a heading's colour is the
   heading's). It takes the underline on hover and focus that says it is
   pressable, and the 44px floor at phone widths that every other control in
   this product keeps. */
.data th .table-sort {
  color: inherit;
  text-decoration: none;
  /* inline-flex + min-height, NOT line-height: a 44px line-height dropped
     the label 14px below its plain neighbours at 390 (design seat, Round A). */
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  line-height: inherit;
}
.data th .table-sort:hover,
.data th .table-sort:focus-visible { text-decoration: underline; }
/* THE SORT SAYS SO. A sortable header was byte-identical to a dead one, and
   the column in force identical to the others; `aria-sort` was the only
   carrier. A glyph after the label — muted where sorting is offered, the
   arrow where it is in force — is what a sighted reader gets. `--muted`
   for the offer, the heading's own ink for the arrow. */
.data th[aria-sort] .table-sort::after {
  content: "\2195";
  margin-left: 0.35em;
  color: var(--muted);
  font-size: 0.85em;
}
.data th[aria-sort="ascending"] .table-sort::after { content: "\25B2"; color: inherit; }
.data th[aria-sort="descending"] .table-sort::after { content: "\25BC"; color: inherit; }
@media (min-width: 960px) {
  .data th .table-sort { min-height: 0; }
}

table.data th.num, table.data td.num {
  font-variant-numeric: tabular-nums;
  text-align: right;
  /* Both page-scoped copies this rule replaced carried `white-space:
     nowrap` too (functionality review, 2026-09-08: no cell wraps at 390
     in the tree today, but a narrower column arriving later should not
     have to rediscover why a number split across two lines). */
  white-space: nowrap;
}

/* A cell that is ONE token — a date, a person's name, a reference — and
   must not be broken to fit a phone: `classes: "nowrap"` on the head cell
   and `class="nowrap"` on the row cells. The `.table-scroll` wrapper is
   what absorbs the width. A date wrapped to three lines made every row of
   the job page's History 76px tall at 390 (scheduler walk, 2026-09-17). */
table.data th.nowrap, table.data td.nowrap {
  white-space: nowrap;
}

/* A TABLE WHOSE CELLS ARE SENTENCES: `table({ classes: "prose-cells" })`.

   `nowrap` is the answer for a column that is ONE TOKEN. It is the wrong
   answer for a change log, where every cell is prose and must wrap — so at
   390 the four columns of "Changes to this record" shared 326px, "Follow up
   order filled in" wrapped to three lines and Detail to six, and the table
   COMPRESSED where the wrapper exists to make it SCROLL (design review,
   Round J, on the ticket page and the job page at once: one is a faithful
   copy of the other, so the shape is fixed once for both).

   A MINIMUM, NOT A WIDTH: above 34rem the table is fluid exactly as it was,
   and below it `.table-scroll`'s `overflow-x: auto` does the job it is there
   for. 34rem over four columns is ~136px each, which is two short words —
   the point at which wrapping stops being reading. */
table.data.prose-cells {
  min-width: 34rem;
}

/* --- kvRow --------------------------------------------------------------- */
/* Moved from ops.css:1453-1491.
   Key-value voice — Phase E extraction from the scheduling drawer.
   Two shapes, one voice:
   - .kv-row: a fact as a row (micro-label left, value right, hairline rules).
   - bare .k / .v: the same label+value voice for containers with their own
     layout (meta-cards etc.) — for years these classes had NO styles and
     rendered at body size, patched with inline styles per view.
   Scoped treatments (.to-balance, .sched-reading) deliberately override the
   base by specificity. Micro-label size is 0.7rem, the scale step both the
   drawer's 0.62 and the app's 0.72 labels absorb into (design/scale.md). */
.kv-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 0.75rem;
  padding: 0.4rem 0;
  font-size: 0.85rem;
}
/* Between-row rule via adjacency, not :first-of-type — a host that renders
   any other div before the list would otherwise keep a stray top border. */
.kv-row + .kv-row { border-top: 1px solid var(--line); }
.kv-label, .k {
  flex: none;
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted);
}
.kv-label { align-self: center; }
.kv-value, .v {
  min-width: 0;
  overflow-wrap: anywhere;
  font-size: 0.85rem;
  font-weight: 500;
}
.kv-value { text-align: right; }
/* 600, not 650 — IBM Plex loads 400/500/600/700; 650 was synthesized. */
.kv-value.strong { font-weight: 600; font-variant-numeric: tabular-nums; }
.kv-value-slot { font-variant-numeric: tabular-nums; }

/* ON A PHONE THE LABEL YIELDS, NOT THE VALUE. `flex: none` on the label is
   right on a desktop — a micro-label should not stretch — and wrong at 390,
   where it holds a whole sentence's width and leaves the amount three
   characters. Standings' "Recent wins" overflowed its panel by 47px because
   of it, with "$4,200.00" rendering one character per line: the label is
   prose and wraps happily, the money is the one thing in the row that cannot.
   So below 500px the label becomes shrinkable and the value stops being.

   A PHONE OVERFLOW IS A HOUSE RULE, NOT A DESIGN RULING (AGENTS.md: no
   horizontal overflow at ~375px), which is why this lands in the wave that
   said it would make no design changes. It moves nothing at any width where
   the row already fits — a flex item only shrinks when there is not enough
   room — and nothing at all above 500px.
   MEASURED, TWICE, AND THE FIRST VERSION WAS WORSE. Making the label
   shrinkable AND pinning the value with `flex: none` fixed Standings (437px
   of scrollWidth against a 390 viewport, down to 390) and broke `/my-day`,
   which has a link and a caption inside one value: 390 -> 492. Only the label
   half was ever the bug. Both stay shrinkable now, and both pages measure 390
   against 390. */
@media (max-width: 500px) {
  .kv-label { flex: 1 1 auto; min-width: 0; }
}

/* ONE CONTEXTUAL ACTION PER ROW (Round F; design-principles.md 2026-09-18).
   The action sits at the end of the row where it cannot join the sentence
   under the value. `flex: none` for the same reason `.kv-label` has it — one
   word must not be the thing that shrinks. No new token and no new colour: it
   inherits the link ink the panel already serves.
   44px at phone width is SKILL.md section 7's wet-glove floor, and this is a
   real target rather than a word inside a paragraph. */
.kv-action {
  flex: none;
  align-self: center;
  font-size: 0.8rem;
  white-space: nowrap;
}
/* A ROW WITH AN ACTION HAS A VALUE COLUMN (design review of Rounds F and G,
   MAJOR 1 / UX walk M4). Two children and `justify-content: space-between`
   put the value's right edge ON the row's right edge, which is what gives a
   list of facts its column. A THIRD child makes the value a middle item, so
   its right edge became content-determined and landed somewhere different on
   every row: measured at a verified 1366 on "How it is served", five values
   at 974, 1131, 1176, 1123 and 1252 — 278px of stagger under a heading whose
   whole purpose is to be read down.

   So a row that carries an action is a GRID, not a flex box: a fixed label
   track, the value in the flexible one, the action in an `auto` one at the
   end. The track is a length rather than `max-content` on purpose — every
   row resolves it to the same number, so five facts start at one x whatever
   their labels are, and a panel that mixes long labels with short ones does
   not re-flow when one of them changes.

   12rem is "TECHNICIAN PREFERENCES" at the micro-label's own size with its
   letter-spacing, which is the widest label this row shape carries in the
   tree; a longer one wraps inside its track rather than pushing the column.

   AND THE VALUE READS LEFT, at every width. `.kv-value` is right-aligned
   because a right-aligned money column is what a two-child row is for; a
   third child turns the same rule into ragged-left prose, and the value's
   `sub` — a whole sentence, sometimes a standing note — inherits it. The
   value and its sub are one left-aligned column, value above sub, both flush
   with the same x.

   AND THE DECISION THE DESIGN SEAT ASKED FOR, made here once (design-h MINOR
   13, referred by builder Q): `.kv-value > .sub { text-align: left }` for
   EVERY kvRow was the other candidate, and it is not right. A two-child row
   is a right-aligned column — that is what it is for, and its `sub` is
   usually a stamp or a basis under a figure ("as of Sep 10", "over 12
   visits"), which belongs under the number it qualifies. What has no left
   edge to return to is a row whose `sub` is PROSE, and that is the
   three-child row: the value is a fact with a whole sentence under it and an
   Edit beside it. So the rule is scoped to `.has-action`, which is exactly
   the four panels the seat measured — the job's "How it is served", the
   quote's "How it will be served" and the account's Bodies of water. */
.kv-row.has-action .kv-value { text-align: left; }
@media (min-width: 501px) {
  .kv-row.has-action {
    display: grid;
    grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) auto;
  }
}
/* A ROW WITH NOTHING TO EDIT, UNDER ROWS THAT HAVE ONE, KEEPS THEIR COLUMN (the
   orchestrator's look at the merged account page, 2026-09-19). "At this
   address" follows "Getting in" and "Visits allowed" in one list and carries
   no Edit — its own button sits under it — so it fell back to the two-child
   rule and printed its value and its sentence flush RIGHT beneath two rows
   that read flush left. One list, one value column: the same tracks without
   the third. A sibling selector and not a class, so no template has to know. */
.kv-row.has-action ~ .kv-row:not(.has-action) .kv-value { text-align: left; }
@media (min-width: 501px) {
  .kv-row.has-action ~ .kv-row:not(.has-action) {
    display: grid;
    grid-template-columns: minmax(0, 12rem) minmax(0, 1fr);
  }
}
@media (max-width: 959px) {
  /* THE INK STAYS FLUSH RIGHT AND THE BOX GROWS TO 44 (design MINOR 17).
     The `min-height` alone gave a 22 x 44 target: "Edit" at 0.8rem is 22px
     wide, and there was no `min-width` and no horizontal padding. The
     negative margin is the trick `.btn.tiny` already uses, so the word does
     not move while the target does. */
  .kv-action {
    display: inline-flex;
    align-items: center;
    justify-content: flex-end;
    min-height: 44px;
    padding-inline: 0.75rem;
    margin-inline-end: -0.75rem;
  }
}
/* THE HOUSE FOCUS RING, not Chrome's browser blue (design MINOR 18). The row
   disclosure added in the same round takes these two lines
   (`visits.css:.visit-row:focus-visible`); the action beside it did not. */
.kv-action:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* AND AT PHONE WIDTH THE LABEL TAKES ITS OWN LINE (Round F, measured at a
   verified 390 in Chrome — `documentElement.clientWidth` 390, not a window
   clamped at 500 reporting 390).

   The rule above this one makes `.kv-label` shrinkable under 500px so the
   value is not squeezed to three characters. A third child in the row takes
   that further than the label's own words: "PREFERRED DAYS" needs 72px and
   was given 39, and a `.kv-label` has no `overflow-wrap`, so the uppercase
   ran straight across the sentence beside it. Two rows of this panel printed
   their label on top of their value.

   `overflow-wrap: anywhere` on the label was tried first and rejected: it
   stops the overlap and renders "PREF / ERRE / D / DAYS", a micro-label
   broken mid-word. This wraps the ROW instead — the label on its own line,
   then the value and the one action beside it — which is the shape a row
   this dense wants anyway. Measured: rows 170px tall become 114px, the
   action stays a 44px target, and the page measures 390 against 390.

   `flex: 1 1 0` on the value and NOT `auto`: an auto basis is the value's
   max-content, which is a whole sentence, so it fills the second line on its
   own and pushes the action onto a third — the action then sits at the far
   left, a line below the fact it acts on. */
@media (max-width: 500px) {
  .kv-row.has-action { flex-wrap: wrap; }
  .kv-row.has-action .kv-label { flex: 1 0 100%; }
  .kv-row.has-action .kv-value { flex: 1 1 0; min-width: 0; }
}

/* --- disclosure ---------------------------------------------------------- */
/* TWELVE COPIES OF ONE DECLARATION, CONSOLIDATED.
   Thirteen ways to draw a summary meant thirteen separate rules hiding the
   triangle each one drew. Twelve of them were identical —
   `X::-webkit-details-marker { display: none }` — and are the selector list
   below, moved unchanged from ops.css:407, :706, :1650, :1763, :2374, :2432,
   :2717, :3539, time-off.css:178, :209, :218 and scheduling.css:101.

   The thirteenth is NOT here on purpose: scheduling.css:1598 groups
   `::-webkit-details-marker` with `::marker` and sets `content: ""` as well
   as `display: none`, so folding it in would either change what the other
   twelve do or split one rule in half. It stays where it is.

   Nothing competes with these — `::-webkit-details-marker` appears nowhere
   else in any sheet — so order is irrelevant and this is a pure move. */
.ops-info-edit > summary::-webkit-details-marker,
.side-nav summary.nav-section::-webkit-details-marker,
.ops-info-collapse > .ops-info-collapse-summary::-webkit-details-marker,
.visit-card-summary::-webkit-details-marker,
.user-card-summary::-webkit-details-marker,
.add-user-panel > summary::-webkit-details-marker,
.compare-picker > summary::-webkit-details-marker,
.gear-menu > summary::-webkit-details-marker,
.to-approval-row::-webkit-details-marker,
.visit-row::-webkit-details-marker,
.to-cell-update > summary::-webkit-details-marker,
.to-type-edit > summary::-webkit-details-marker,
.sched-tech-menu > summary::-webkit-details-marker { display: none; }

/* --- panel: the right-hand meta slot -------------------------------------- */
/* Nothing in web/views carries .panel-head or .panel-meta, so — the argument
   css-debt-audit.md Claim 3 makes for .panel.warning — this cannot alter any
   surface that renders today; it can only add the affordance the panel
   component promises. Layout only, no colour: .panel .sub already supplies
   the quiet type, and .panel-meta wears `sub` alongside. */
.panel-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 0.75rem;
  flex-wrap: wrap;
}
.panel-head > :first-child { margin-right: auto; }
.panel-meta { flex: none; margin-bottom: 0; }

/* --- panel: the vertical rhythm ------------------------------------------- */
/* THE PANEL HAD NO GAP OF ITS OWN, and every page that dropped a wrapper class
   on the way into this library lost the one it was borrowing. Measured at 1366
   before this rule: `/my-day` had SIX consecutive panel-to-panel gaps of 0px,
   `/inventory` seven, `/crm/invoices` three, `/scheduling/today` three,
   `/pulse` three, `/tickets`, `/crm/quotes`, `/expenses`, `/time-off` and
   `/crm/accounts` two each, `/alerts` one — panels touching, hairline against
   hairline, reading as one box with a rule through it. The pages that looked
   right looked right by accident: `/dashboards` has its gap because
   `.dash-toolbar` happens to carry a `margin-bottom`.

   1.25rem is not a new number. It is what `.hero` and `.kpi-grid` already put
   under themselves, so a page reads as one rhythm rather than three.

   ADJACENCY, NOT A BOTTOM MARGIN ON EVERY PANEL: a trailing margin under the
   last panel on a page is space nothing asked for, and a panel that is the
   only child of a grid cell would carry it too.

   AND THE CONTAINERS THAT LAY THEIR PANELS OUT THEMSELVES ARE EXCLUDED. A
   margin on a flex or grid ITEM ADDS to the container's `gap`, it does not
   replace it — so `.grid-2`'s two chart frames would sit 1rem apart at 1366
   (side by side, unaffected) and 1rem + 1.25rem apart at 390, where they
   stack. The list below is not a guess: every parent of a `.panel` in the
   product was surveyed in Chrome at 1366 AND at 390 across thirty pages, and
   these are the seven that came back `display: flex`/`grid` with a `gap`.
   `.pulse-stack` is deliberately NOT among them — it is `display: contents`
   above 1023 and a `gap`-less flex column below, so it wants the rhythm at
   both widths. `.sched-panes > .panel { margin: 0 }` already exists in
   `scheduling.css` and would win on load order anyway; it is named here so
   the rhythm cannot come back by somebody deleting that line. */
.panel + .panel,
/* A <script> RENDERS NOTHING AND MUST NOT BREAK THE RHYTHM. `stock_overview.njk`
   drops one between two panels and the adjacency stopped there: measured at
   1366, six of that page's seven panel gaps came out at 20px and the seventh
   at 0, for no reason a reader could see. `+` is a DOM adjacency, not a visual
   one, and a display:none element is still an element. */
.panel + script + .panel { margin-top: 1.25rem; }
.grid-2 > .panel + .panel,
.grid-3 > .panel + .panel,
.dash-grid > .panel + .panel,
.home-section-grid > .panel + .panel,
.pulse-charts > .panel + .panel,
.sched-panes > .panel + .panel,
.sched-page > .panel + .panel,
.sched-agenda-list > .panel + .panel { margin-top: 0; }

/* --- filterBar ------------------------------------------------------------ */
/* THE COMPONENT HAD NO RULE OF ITS OWN, so five callers bought the one thing
   it needs — a gap under it — through the `classes:` escape hatch and a
   per-page class each: `.aa-filter`, `.adr-filter` (twice), `.ant-filter` and
   `.mcl-filter`, all of them `margin-bottom: 1rem` in `ops.css`. That is a
   component's default written five times in the pages that use it, which is
   the shape the library exists to end.

   1.25rem, not their 1rem, because it is the same rhythm `.panel + .panel`,
   `.hero` and `.kpi-grid` carry — a filter bar is a panel and should not sit
   at a different distance from what follows it than any other panel does.

   AND THE ADJACENCY IS ZEROED AFTER IT, because a filter bar is almost always
   followed by a panel and would otherwise carry BOTH gaps: 1.25rem of its own
   plus 1.25rem of the rhythm. This declares the gap on the filter bar rather
   than leaving it to the rhythm because a filter bar is also followed by a
   bare `<table>`, a `<ul>` or a paragraph, and those get no rhythm at all.

   The five per-page classes are no-ops from this commit — this sheet loads
   after `ops.css` and both selectors are one class, so 1.25rem wins — and
   their deletion is the view builders' to make. */
.filter-bar { margin-bottom: 1.25rem; }
.filter-bar + .panel { margin-top: 0; }

/* ONE ROW ON A DESK. `.ops-info-form-grid` is two fixed columns above 640,
   which is right for a FORM — a name beside a SKU, both wanting the same
   width — and wrong for a filter bar, where "Search" wants a lot of room and
   "On order" wants as much as its words. /inventory's five controls took
   THREE rows and 265px at 1366, and the first row of data landed at y=702.
   A reader came for the table.

   FLEX, NOT A COLUMN COUNT, because this rule is the component's and the
   component has 20 callers with 2 to 6 controls each. A fixed
   `grid-template-columns` with five tracks would squeeze a two-field bar and
   strand a six-field one; flex-wrap gives each bar as many rows as it needs
   and no more. A two-field bar keeps the two equal columns it has today: both
   items are `flex: 1 1 11rem` in a container far wider than 22rem, so they
   split it.

   `.field-check` IS AS WIDE AS ITS WORDS. A checkbox row given an 11rem basis
   is a box, three words and four centimetres of nothing.

   ALIGNMENT IS THE GRID'S, deliberately: no `align-items` here, so items
   stretch and every control still starts one line of label text down — the
   property `field`'s own M4 fix bought, which `align-items: flex-end` would
   throw away the moment one field carried a hint and its neighbour did not.

   `:has(> label:nth-of-type(3))` — THREE FIELDS OR MORE, and that guard is
   measured, not cautious. A one- or two-field bar is ALREADY one row of the
   right shape: two flex items at `1 1 11rem` in a 1041px container split it
   512/512, which is exactly what `1fr 1fr` gave them (checked on
   /expenses/vendors, /tickets, /admin/ideas and /admin/deleted-records — 1
   row and the same height either way). The one that is NOT already right is a
   bar with a single field: the grid leaves it in column one at 512px and flex
   would grow it to 1041, so /sites' lone search box would have doubled in
   width for no reason anybody asked for. The guard costs one selector and
   leaves every such bar exactly as it is. */
@media (min-width: 960px) {
  .filter-bar .ops-info-form-grid:has(> label:nth-of-type(3)) {
    display: flex;
    flex-wrap: wrap;
  }
  .filter-bar .ops-info-form-grid:has(> label:nth-of-type(3)) > * { flex: 1 1 11rem; }
  .filter-bar .ops-info-form-grid:has(> label:nth-of-type(3)) > .ops-span-2 { flex: 2 1 18rem; }
  .filter-bar .ops-info-form-grid:has(> label:nth-of-type(3)) > .field-check { flex: 0 0 auto; }
}
/* The row under the form, inside the panel: the insights pages' period
   presets, which used to sit in a bare div AFTER the panel, so the panel's own
   hairline ran between a filter and the six buttons that set it. */
.filter-bar-footer { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }

/* --- panel: the ok and danger tones --------------------------------------- */
/* A new rule, not a move (ruling, 2026-09-08). `.panel.warning` (ops.css:1399)
   was the only tone with a rule; `status`'s `tone: "ok" / "danger"` set a
   class nothing matched, so a refusal and a success both rendered as
   ordinary white furniture — the same defect css-debt-audit.md Claim 3 wrote
   about `.panel.warning`, one class along. Shape borrowed from
   `.sched-integrations .panel.danger`, the one place this already existed
   (scoped, now deleted): a left rule, no fill, so the sentence inside stays
   at full contrast rather than sitting on a tint. `--good-ink`/`--danger-ink`,
   not the water-chemistry `--ideal`/`--ok`/`--emergency` tokens the pill's
   `severity` axis owns — SKILL.md is explicit that severity is chemistry and
   nothing else, and a status panel is not a chemistry reading. */
.panel.ok { border-left: 3px solid var(--good-ink); }
.panel.danger { border-left: 3px solid var(--danger-ink); }

/* AND `warning` IS THE THIRD, moved here from `ops.css` so all three tones of
   one axis are declared together. It was the last of the four geometries the
   census found for "what just happened": `ok` and `danger` had NO rule at all,
   so the 2026-09-08 "five unstyled options" fix gave them this shape and
   skipped `warning`, which already had SOME styling — a cream fill with an
   amber border. That left the tone a reader is most likely to meet looking
   like a different component from its two neighbours.

   `--warn-edge`, NOT a `--warning-ink`. There is no such token, and inventing
   one would be a fifth name for the same amber: `ops.css` `:root` declares the
   pair as `--warn-ink` ("only ever text on --warn-surface", 6.84:1) and
   `--warn-edge` ("only ever a border or inset rule"), and this is a border.
   Using `--warn-ink` here would be spending the text token on a rule, which is
   exactly the confusion those two comments exist to prevent. The colour is the
   contrast-verified amber either way.

   Ten call sites across nine templates changed look when this landed,
   `layout.njk`'s licence banner among them; `test/features.test.js`'s only pin
   checks the class attribute, not the fill. */
.panel.warning { border-left: 3px solid var(--warn-edge); }


/* --- `.sub`: the line under a label, and under a value --------------------- */
/* `.sub` HAS NO DISPLAY RULE ANYWHERE IN THE PRODUCT. The only `.sub` rule
   that resolves everywhere is `.panel .sub` (ops.css:953), which sets colour,
   size and a bottom margin and leaves the box INLINE. Two components put a
   `.sub` somewhere that is not a panel's body and both read as run-on prose
   because of it:

     field   its `sub` is documented as "the line under the label", and the
             standing default for a paragraph somebody cut is "an inline hint
             on the field — a `field`'s `sub` line" (SKILL.md, 2026-09-09).
             Rendered inline it reads "Which body of water Naming it is what
             lets a chemical incident on it join this ticket".
     kvRow   its `sub` is "the basis, the window, the 'as of'". `/my-day` read
             "11 stops overdue The board's own count, after each cadence's
             grace" as one line — 15 such rows on the account and job pages,
             which are exactly the pages that do not load `service.css`.

   `> .sub`, not a descendant selector, in both: a caller may put a `.sub`
   INSIDE the control slot (the ticket intake's `data-narrow-hint` span) or
   inside a value, and that one is already on its own line. And the whitespace
   either side of `kvRow`'s span is load-bearing for a pill drawn beside the
   figure, which is why the rule is on the span and not on the value's text. */
/* AND THE GRID, NOT ONLY THE FORM (Round 5). `field`'s contract says the hint
   goes UNDER the control, and this one rule is what puts it there — but it was
   keyed on `.ops-info-form`, the FORM's class, while the macro's documented
   home is `.ops-info-form-grid`. A grid that is not inside a form — the Scan
   panel's confirm strip and its New item strip, both of which are `<div>`s,
   because a scan is posted by fetch and has no form to submit — got the hint
   rendered INLINE instead: measured at 390, "Your part number, if any." wrapped
   around the right edge of the SKU box and back under the next label. Adding
   the grid covers every `field` call there is, and changes nothing for the
   grids already inside a form. */
.ops-info-form label > .sub,
.ops-info-form-grid label > .sub { display: block; margin-top: 0.1rem; }
/* AND `status({ inline: true })` INSIDE A GRID FIELD, for the same reason.
   The component's own word is "beside a control", and beside is right where
   the region sits next to a lone input. In a form GRID it is the last child
   of a `<label>` that already holds the control, sometimes a button and a
   hint — measured on Record a movement at 1366, "Chlorine tablets 3" (25 lb
   bucket) picked." wrapped around the "Scan it" link and back under the
   select. Its own line, under the control, like the hint. */
.ops-info-form-grid label > .form-status { display: block; margin-top: 0.1rem; }
.kv-value > .sub { display: block; margin-bottom: 0; }
/* A link INSIDE PROSE — a panel's sub line, a kvRow's value — is underlined.
   `--accent` against `--muted` measured 1.00:1 on the Round 2 pointers
   (design and UX seats, 2026-09-17): a link by colour alone in a grey
   sentence is not a link. Buttons and rows keep their own affordance. */
.panel .sub a, .kv-value a { text-decoration: underline; text-underline-offset: 2px; }

/* --- listRow: the list its rows live in ----------------------------------- */
/* `listRow` renders an `<li>` and the library gave a caller nowhere to put it:
   `.settings-cards`, `.ex-list`, `.inbox-list` and `.pulse-waiting` are four
   bespoke containers for one primitive, and a caller with no list of their own
   got browser bullets and 40px of indent.

   The geometry is `.inbox-list`'s, which is the one the 2026-09-08 ruling
   already blessed for `li.unread`: a hairline between rows, the whole row a
   target at the 44px floor, and meta on one line that truncates with an
   ellipsis rather than wrapping — the compact-list default of 2026-09-09. */
.list-rows { list-style: none; margin: 0; padding: 0; }
.list-rows > li + li { border-top: 1px solid var(--line); }
.list-rows > li > * {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.15rem 0.5rem;
  min-height: 44px;
  padding: 0.5rem 0;
  text-decoration: none;
  color: var(--ink);
}
.list-rows a:hover { background: var(--surface-sunk); }
/* Basis zero so a long title grows into what is left beside the trailing chip
   and wraps inside it, rather than deciding the line break with its own
   max-content width — the lesson `.pulse-waiting .t` records. */
.list-rows .t { font-weight: 600; flex: 1 1 0; min-width: 0; }
.list-rows .sub {
  flex-basis: 100%;
  margin-bottom: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A meta-card whose value is a SENTENCE rather than a figure reads at body
   weight instead of the grid's number weight. It was an inline
   `style="font-size:.85rem;font-weight:500"` on the triage page, which no
   ratchet could see. Equal specificity to `.meta-card.overdue .v` in ops.css
   and this sheet loads after it, so the pairing is unchanged by the move. */
.meta-card .v.meta-v-prose { font-size: 0.85rem; font-weight: 500; }

/* --- the accent every native control paints itself with -------------------- */
/* NEVER SET ANYWHERE UNTIL NOW (design review, Round 6). Every checkbox, radio
   and range in the product painted browser blue — the ONE colour the palette
   does not contain — beside a chrome that is navy and teal. It is one
   declaration and it reaches every native control on every page, including the
   three checkbox surfaces this round added.

   ON `:root` AND NOT ON A COMPONENT, because `accent-color` inherits: setting
   it here is what makes it a property of the product rather than of whichever
   controls somebody remembered. `--accent` and not `--brand-primary`: this is
   the colour that marks what is live and what is pressed, which is exactly
   what a ticked box is, and it is one of the eleven a client theme may
   override — so a themed box gets its own accent in its checkboxes for free. */
:root { accent-color: var(--accent); }

/* --- field: a checkbox is a tap target too --------------------------------- */
/* The control-height ruling (2026-09-08) sets `.btn`, `input`, `select` and
   `textarea` to 36px on a desk and 44px at <=959px and EXCLUDES checkboxes and
   radios in as many words — "they are a tap target of their own shape". The
   tap target it was deferring to did not exist: nothing in the product gave a
   checkbox row a height except `.grant-list label` on the licence page, and
   SKILL.md §7 asks for 48px on a phone, "deliberately different for wet
   gloves". Measured at 390 before this: the auto-run box on `/alerts` and the
   "went to the client" box on a ticket were both a 13x13 input.

   THE LABEL IS THE REAL TARGET — `field` wraps the control, so a tap anywhere
   on the row toggles it — so the row gets the height and the box gets a size a
   thumb can see. `> input`, so a checkbox a caller puts inside a list further
   down is left alone. */
/* THE FOUR RULES THAT WERE HERE ARE GONE, lifted to the bare
   `label:has(> input)` selector further up this file (design review, Round 6,
   M1) so every checkbox row in the product gets the same geometry rather than
   only the ones inside a form somebody remembered to class. Nothing about the
   numbers changed: 44px and a 1.25rem box on a desk, 48px and 1.5rem at
   <=959px, and a `.sub` under a row on its own line. */

/* --- pill: the shape, and all three palettes ------------------------------ */
/* MOVED HERE WHOLE from `ops.css` (and `.pill.kind-maintenance` from
   `service.css`), wave 3, because the pill's whole rule is that its three
   axes are NOT interchangeable and that cannot be checked while they are
   declared in three files. `tests/test_design_tokens.py` now reads this block
   and measures every tone.

   THE THREE AXES, in the component's own words:
     status     where a THING stands: ok, warning, danger, info, unknown,
                neutral.
     kind       WHAT a thing is: a line of business, a work type, a tag, a
                source. `kind-` prefixed wherever the word collides with the
                severity list, which is the convention `.pill.kind-idea` and
                `.pill.kind-bug` already used.
     severity   WATER CHEMISTRY AND NOTHING ELSE: ideal, ok, warning,
                emergency, maintenance.

   EVERY INK IS >= 4.5:1 ON ITS OWN TINT NOW, and four were not. Measured with
   WCAG relative luminance against the tokens this file serves (the numbers are
   in `tests/test_design_tokens.py`, which fails if any of them slips):

     tone                 was                          is
     warning     --warning     2.78:1        --warn-ink    6.19:1
     ok          --ok          3.01:1        --info        4.86:1
     danger      --emergency   3.95:1        --danger-ink  5.38:1
     ideal       --ideal       3.04:1        --good-ink    5.69:1
     store       --store       3.86:1        --ink         9.71:1
     tag         --accent      3.80:1        a 70/30 accent-to-ink mix, 4.95:1

   THE TINT IS NEVER WHAT CHANGED. Every fill below is the one the product has
   always drawn, so no page is recoloured and no chip changes what it means —
   it is the WORD that was too pale to read on it. `--warning`, `--ok`,
   `--emergency` and `--ideal` stay exactly as they are in `:root`, because
   `charts.js` draws lines in them and `test_design_tokens.py` pins the two
   halves together; what changed is which token the pill's TEXT uses. The
   swap is the precedent `.kpi .delta` set in the 2026-09-08 ruling, for the
   same reason and with the same pair of tokens.

   `--accent` CAN NEVER PASS AS PILL INK and that is worth stating once: the
   token's own default value measures 4.42:1 on WHITE, so no wash of it over
   white can reach 4.5 — a lighter fill cannot help, because the fill is what
   it is being measured against. (The value is not quoted here on purpose:
   this sheet's raw-hex ceiling is ZERO and stays there, unlike `nav_v2.css`'s
   six lines of measured prose. The ratio is the checkable claim; the token
   resolves to the value in `ops.css` `:root`, which is the one place.) `.pill.tag`'s ink is therefore a mix of the accent with
   the page's own ink: still the accent's hue, still the client's accent on a
   themed box, dark enough to read. */
.pill {
  display: inline-block;
  padding: 0.15rem 0.5rem;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 600;
  /* A PILL IS A CHIP AND A CHIP DOES NOT BREAK IN HALF (design review,
     Round 6). Same shape as the `.num` ruling one review earlier: a property
     the component always wanted, declared once here rather than discovered per
     page — `th.num` was right in two page sheets and wrong in ninety tables,
     and `nowrap` is right in every pill and was declared in none.

     THE SYMPTOM was "In / use" in the Categories table's In use column at 390,
     where a two-word chip in a squeezed cell wrapped between its words inside
     the rounded background. The tree ships about forty multi-word pills, so
     that column was the one that got narrow enough first, not the only one at
     risk.

     A SENTENCE-LENGTH PILL IS STILL WRONG and this does not fix it — it makes
     it a chip that runs off the side instead of one that breaks in half. The
     answer there is a short pill and a sentence beside it, which is a content
     change per caller and is filed in `ui-backlog.md`. */
  white-space: nowrap;
}

/* ── status: where a thing stands ───────────────────────────────────────── */
.pill.ok { background: var(--ok-tint); color: var(--info); }
/* `warn` IS AN ACCEPTED SPELLING OF `warning`, and it is here rather than in
   three page sheets. The status axis's word is `warning`; two views pass
   `status: "warn"` (`scheduling.njk`, `time_off_approvals.njk`) and the
   component emits `.pill.warn`, which had a rule in `scheduling.css` twice
   (`.sched-techs`, `.sched-col-head`) and in `time-off.css` once — three
   copies of one tone, each scoped to a page, and the board's "2 open here"
   chip sits inside none of the three. Measured live at 1366: no fill at all,
   and its text at 2.64:1.

   That is `.btn.tiny`'s defect exactly ("defined only in scheduling.css,
   which five pages loaded"), so it takes the same answer: one rule, in the
   sheet every page loads, drawing the tone the word means. The three scoped
   copies are deleted. They also drew a slightly different chip
   (`--warn-surface` rather than `--warning-tint`), which is the second half
   of the same problem — one tone should not be two.

   THE ALIAS IS A RESIDUE, not a vocabulary. `warn` is not on the documented
   status axis and the two call sites should say `warning`; this rule goes the
   day they do, and it is named in `pattern-library.md`'s residue list so that
   is somebody's line rather than nobody's. */
.pill.warning, .pill.warn { background: var(--warning-tint); color: var(--warn-ink); }
/* TWO SELECTORS, NOT THREE (deletion pass, wave 4). `.pill.critical` sat here
   as a third name for this one tone, and six call sites fed it: `/alerts`,
   `/alerts/<id>`, `/incidents/<id>` twice and `/rules` twice, all of them
   passing an incident's own vocabulary — `critical|warning|info`, pinned by
   `api_app.py`'s pattern — straight through to a class. Two of those three
   words are on the pill's status axis and `critical` never was, so one tone
   wore two class names depending on which page a reader was on, which is the
   `.pill.warn` residue exactly one row up. The callers say `danger` now (the
   word in the chip is unchanged, and this rule's declarations are unchanged,
   so nothing renders differently), and `web/test/pill_tone_aliases.test.js`
   fails on the first `pill critical` anybody writes back.
   `emergency` STAYS: it is not an alias but the severity axis's own word,
   which the macro emits for `severity: "emergency"` and refuses as a status. */
.pill.emergency, .pill.danger {
  background: var(--emergency-tint);
  color: var(--danger-ink);
}
.pill.info { background: var(--info-tint); color: var(--info); }
.pill.unknown { background: var(--neutral-tint); color: var(--muted); }
/* `neutral` HAD NO RULE AT ALL until the Service sweep, so seven callers —
   "New", "Open", a normal/low priority, an incident with no severity — drew a
   chip with a class nothing matched: no fill, no radius, no padding. That is
   why the 2026-09-09 critique read a STATUS column as "New as plain text
   beside Needs client input as a pill" (finding 7).
   A HAIRLINE, BECAUSE THE FILL CANNOT DO IT. `--neutral-tint` measures
   1.10:1 against a panel — and so does every other tint in this palette
   (1.04 to 1.23): a chip's fill is a tint by definition and no tint reaches
   the 3:1 a non-text boundary wants. Making THIS one dark enough would have
   made the quietest chip in the product the loudest. The border is the
   boundary instead, at `--edge-strong`, and the padding gives back the 1px so
   a neutral pill is exactly as tall as the pill beside it — measured, because
   a chip 2px taller than its neighbour in a status column is the defect one
   along. `unknown` deliberately keeps the same fill and ink: the two mean
   different things ("nothing was recorded" against "recorded, and ordinary")
   and a reader should not have to tell them apart by colour. */
.pill.neutral,
/* THE CLIENT-TAG FAMILY'S DEFAULT, which is the neutral chip and deliberately
   the same rule rather than a copy of it (seam review, wave 3).
   `scheduling.njk` emits `.pill.tag-<id>` over a CLOSED nine-value domain
   (`CLIENT_TAG_DEFS` in `src/kadunko/app_metadata.py`), and exactly two of the
   nine had a rule — in `scheduling.css`, which five pages load. The other
   seven — Residential, Commercial, Multiple Visits Per Week, Weekly, Monthly,
   Maintenance Prospect and Dog on site — drew bold text in a pill's padding
   with no chip behind it, on the board where a dispatcher reads them fastest.

   AN ATTRIBUTE SELECTOR, NOT NINE CLASS NAMES: the domain is closed and it
   lives in Python, so writing it out here would be a second copy that goes
   stale the day somebody adds a hazard — which that file's own comment says is
   how a hazard gets added. `[class*="tag-"]` matches the prefix and nothing
   else in this palette: `.pill.tag` and `.pill.kind-tag` have no hyphen after
   `tag`, so neither is caught. */
.pill[class*="tag-"] {
  background: var(--neutral-tint);
  color: var(--muted);
  border: 1px solid var(--edge-strong);
  padding: calc(0.15rem - 1px) calc(0.5rem - 1px);
}
/* THE TWO THAT MEAN SOMETHING, after the default so they win at equal
   specificity. These are the two tags that CHANGE WHAT A DISPATCHER DOES —
   `scheduling/cadence.py` reads both by name and nothing else in that list —
   so they are the two that earn a colour. The other seven describe the client
   (a frequency, a property class, a hazard) and are read, not acted on. */
.pill.tag-high_value { background: var(--good-surface); color: var(--good-ink); }
.pill.tag-churn_risk { background: var(--danger-surface); color: var(--danger-ink); }

/* ── severity: water chemistry, and nothing else ────────────────────────── */
/* `ok`, `warning` and `emergency` are shared with the status axis above and
   are deliberately not repeated: a chemistry band and a state of the same
   name have always drawn the same chip, and splitting them here would be a
   design decision this wave has no ruling for. `ideal` and `maintenance` are
   this axis's own. */
.pill.ideal { background: var(--ideal-tint); color: var(--good-ink); }
.pill.maintenance { background: var(--maint-tint); color: var(--maint); }

/* ── kind: what a thing IS ──────────────────────────────────────────────── */
/* THE COLLISION FINDING 8 NAMED: `.pill.maintenance` was ONE class serving two
   axes — the chemistry band and the line of business of the same name — so
   `pill` refused `kind: "maintenance"` outright rather than paint a
   line-of-business tag in the severity palette. The refusal shipped:
   `/compare` drew SIXTEEN bare, unstyled pills, and `/dashboards`' "Default"
   chip was a seventeenth. The macro routes a colliding word to `kind-<word>`
   now, and this is the palette that receives it — one tint per
   line-of-business word, none of them borrowed from a severity token's INK.

   BOTH SPELLINGS OF EACH WORD, because the tree writes both: `kind: "store"`
   passes straight through (it is not a severity word) while a caller who has
   namespaced everything writes `kind: "kind-store"`, and the two must not
   render differently. That is one selector list, not two palettes.

   THE TINTS ARE THE ONES THE DIRECTORY HAS ALWAYS DRAWN. `kind-maintenance`
   and the chemistry `maintenance` are still the same teal, and that residue is
   recorded rather than fixed here: giving a line of business its own hue is a
   colour decision for Ted, and this wave's job was to make the axes separable
   at all. What DID move is `liner`'s ink, off the chemistry `--ideal` (3.04:1)
   onto `--good-ink` (5.69:1) — the tint is unchanged, and a line of business
   should not be reading its text out of the chemistry palette either. */
.pill.maintenance, .pill.kind-maintenance { background: var(--maint-tint); color: var(--maint); }
.pill.store, .pill.kind-store { background: var(--store-tint); color: var(--ink); }
/* Every classification has a pill — a backgroundless "service" beside an
   outlined source badge made the annotation outrank the thing annotated. */
.pill.service, .pill.kind-service { background: var(--info-tint); color: var(--info); }
.pill.construction, .pill.kind-construction { background: var(--sales-tint); color: var(--sales); }
.pill.liner, .pill.kind-liner { background: var(--ideal-tint); color: var(--good-ink); }
.pill.sales, .pill.kind-sales { background: var(--sales-tint); color: var(--sales); }
/* warn family, not --warning: the warning pair was 2.78:1 at pill size and the
   PASSING amber (--warn-ink on --warn-surface, 6.84:1) already sits on the
   same summary line as "default hours". */
.pill.unclassified, .pill.kind-unclassified { background: var(--warn-surface); color: var(--warn-ink); }
/* ONE COLOUR, FROM ONE TOKEN. `.pill.tag` was `rgba(28, 132, 144, 0.12)` — a
   hand-copied literal of All Seasons' teal — under `color: var(--accent)`,
   which a client theme DOES override. So on any themed box the wash and the
   word were two different colours pretending to be one. Both halves come off
   `--accent` now, and the ink is mixed toward the page's ink far enough to
   clear 4.5:1 (see the note at the top of this block on why the bare accent
   never can). */
.pill.tag, .pill.kind-tag {
  background: color-mix(in srgb, var(--accent) 14%, var(--panel));
  color: color-mix(in srgb, var(--accent) 70%, var(--ink));
  font-weight: 500;
}
/* WHAT KIND OF WORK A STOP IS — and the `.btn.tiny` defect for a fourth time
   (design review, Round 7, M2). This rule lived in `scheduling.css` scoped to
   `.sched-queue-item`, so `kind: "job-type"` painted a chip in the QUEUE and
   drew bare grey text everywhere else the same word is written: the board's
   own key ("Estimate visit"), and the origin badge on a queue card, which is
   an `<a>` and not a `.sched-queue-item`. Unscoped here, beside the palette
   that owns the axis, it is one chip wherever the kind is named.

   Deliberately quiet, which is the scoped rule's own reasoning kept: the queue
   is nearly all recurring visits, so this marks the exception rather than
   shouting.
   AND THE MOVE FOUND A DEFECT THE SCOPE WAS HIDING. The rule's ink was the
   bare `var(--accent)`, which measures 4.22:1 on `--surface-sunk` and 4.42:1
   on `--panel` — under the 4.5 a 12px word at weight 600 needs, and invisible
   to `tests/test_design_tokens.py` while the rule lived in a door's sheet
   under a page-specific selector. RETINT THE INK, NOT THE FILL, which is that
   test's own instruction and `.pill.tag`'s answer four rules down: the accent
   mixed toward the page's ink, so it is still the accent's hue and still the
   client's accent on a themed box, and dark enough to read. */
.pill.job-type, .pill.kind-job-type {
  background: var(--surface-sunk);
  border: 1px solid var(--accent);
  color: color-mix(in srgb, var(--accent) 70%, var(--ink));
  padding: calc(0.15rem - 1px) calc(0.5rem - 1px);
}

/* Source badge: outline, quiet — WaterLink people carry NOTHING (badge the
   exception; an all-WaterLink shop sees zero new chrome). */
.pill.source, .pill.kind-source {
  background: transparent;
  border: 1px solid var(--line);
  color: var(--muted);
  font-weight: 500;
  padding: calc(0.15rem - 1px) calc(0.5rem - 1px);
}
/* WARRANTY IS ON THE KIND AXIS, and it shipped on the status one (design
   review, Round 7, M3). It is what a job IS — the shop is honouring an
   obligation — and the kind axis is "never coloured by severity".

   The amber it wore was wrong twice over. It read as a WARNING about a
   perfectly ordinary decision somebody made deliberately; and in the invoice
   register the Status column already spends amber on "partly paid", so a
   warranty invoice carried two amber chips side by side meaning opposite
   things — one "somebody owes us money" and one "nobody owes us anything".

   NEUTRAL HAIRLINE, NOT `--good-*`, and the choice is worth stating because
   green is the tempting one. A warranty is good news to the CUSTOMER and a
   real cost against no revenue to the SHOP, and every reader of this chip is
   the shop: a dispatcher, a bookkeeper, an owner. A chip that means opposite
   things to the two people looking at it must not pick a side with colour.
   Its job is to CLASSIFY — so a $0 does not read as an unpriced draft — and
   the quiet outline is what classification looks like here. It borrows
   `.pill.source`'s shape for that reason, and leaves amber to the Status
   column that earns it. */
.pill.warranty, .pill.kind-warranty {
  background: transparent;
  border: 1px solid var(--line);
  color: var(--muted);
  font-weight: 500;
  padding: calc(0.15rem - 1px) calc(0.5rem - 1px);
}
/* `--maint`, not the raw literal that WAS written here: the same value, the
   token that already declares it, and one raw hex off `ops.css`'s ceiling
   (20 -> 19 in `tests/test_design_tokens.py`, banked in this commit). */
.pill.email-on { background: var(--maint-tint); color: var(--maint); }
.pill.email-off { background: var(--neutral-tint); color: var(--muted); font-weight: 500; }

/* --- listRow: unread ------------------------------------------------------- */
/* A new rule, not a move (ruling, 2026-09-08). The only rule for `unread` was
   `.inbox-list li.unread` (ops.css, now generalised) — `list-row/macro.njk`'s
   `unread` option set the class everywhere, but the rail only ever rendered
   inside the inbox; every other list got the class and the visually-hidden
   "Unread." with no visible mark. Same rail, same token, unscoped. */
li.unread { border-left: 3px solid var(--accent); }
/* `.waiting` generalised the same way (design review, 2026-09-08): a row
   passed `classes: "waiting"` (not a named option, but the escape hatch
   the macro's own comment names for it) only ever got its dotted rail
   inside the inbox, so the fixture demonstrating "waiting, which is not
   unread" rendered as a plain row everywhere else. Dotted, not solid — the
   difference from unread survives being colour-blind. */
li.waiting { border-left: 3px dotted var(--accent); }

/* --- table: the Columns menu ---------------------------------------------- */
/* `table({ columns })`. The menu sits ABOVE the scroller and never inside it:
   `.table-scroll` is `overflow-x: auto`, so a control placed in there scrolls
   sideways away from the reader on exactly the wide table this option exists
   for. Right-aligned, because the reader's eye is on the header row's right
   edge — where the columns they want gone are — and because a control on the
   left would sit under the page's own heading and read as the panel's action.

   THE BOX ITSELF IS `.gear-menu-list` (ops.css): this is
   `disclosure({ variant: "button" })`, so the popover, its border, its radius
   and its shadow are the ones every menu in the product already has. What is
   added here is only what a LIST OF CHECKBOXES needs and a list of links does
   not — a scroller for a fourteen-column table, and a row shape that gives
   each label a hit area rather than leaving it 16px tall beside its box. */
.table-tools {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  margin: 0 0 0.4rem;
}
/* THE TABLE'S NAME, WHERE IT CAN BE SEEN. A real `<caption>` renders inside
   the table box, which on a wide table is inside a horizontal scroller — so it
   slides away from the reader exactly like the menu would. The `<caption>` is
   still there and still the accessible name; this is its visible twin, and it
   is what stops the Columns button floating over a corner of nothing. */
.table-tools-label {
  font-weight: 600;
  color: var(--muted);
  font-size: 0.85rem;
}
/* With no caption the menu keeps the right edge it had. */
.table-tools:not(:has(> .table-tools-label)) { justify-content: flex-end; }
/* Fourteen columns plus a sentence plus Reset is taller than a phone. The
   popover is absolutely positioned, so without this it simply runs off the
   bottom of the screen with no way to reach the last three.

   THE LIST SCROLLS AND THE RESET STAYS, which is the rule `.ops-dialog` keeps
   for its own footer and for the same reason: a control taller than the
   viewport must not hide the one control it exists for. Measured at 1366x768
   on /inventory before this split — thirteen checkboxes and the sentence come
   to about 470px against a 460px cap, so "Reset" sat ten pixels below the
   fold of a box most readers would not think to scroll. The popover's own
   padding moves onto the list so the sticky foot can reach the box's edge. */
.table-columns {
  max-height: 60vh;
  overflow-y: auto;
  padding: 0;
}
.table-columns-list { padding: 0.35rem; }
.table-columns-list > .sub { margin: 0.15rem 0.4rem 0.45rem; }
.table-columns label {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  padding: 0.3rem 0.4rem;
  border-radius: 8px;
  cursor: pointer;
}
.table-columns label:hover { background: var(--bg); }
/* The "(3 hidden)" on the Columns trigger. Quieter than the word beside it —
   the button is still called Columns — and `nowrap` because the trigger is a
   `.btn`, which is inline-flex, and a count that wrapped would make the
   control two lines tall in a row that is measured at 36/44px. */
/* THE GAP IS A MARGIN AND NOT A SPACE. `.btn` is `inline-flex`, so the
   trigger's children are flex items and whitespace between them renders as
   nothing: with a space in the text it read "Columns(6 hidden)", measured in
   Chrome at 1366. The count is `disclosure`'s own `meta` slot now, so the
   margin lives on that — one rule, for every button-variant disclosure that
   grows a meta. */
.gear-menu-meta { margin-left: 0.4rem; }
.table-columns-foot {
  position: sticky;
  bottom: 0;
  padding: 0.35rem;
  background: var(--panel);
  border-top: 1px solid var(--line);
}

/* ===== multiPick + choice (form controls round, 2026-09-20) =====

   `choice` HAS NO RULE IN HERE, AND THAT IS THE POINT OF IT. Two answers draw
   `.ops-fieldset` > `<legend>` > `.ops-radio-row` > `label.field-check`, and
   three or more draw a `field` around a `<select>` — four shapes this sheet
   and `ops.css` already style, because the component was lifted from the
   markup `_follow_up_form.njk` wrote by hand until 2026-09-20. A new rule here would be the
   component changing what adoption renders, which is the one thing the
   library's bargain forbids.

   So everything below is `multiPick`'s, and it is the closed row plus the box
   it opens into. The row is A CONTROL — 36px on a desk, 44px at ≤959px, the
   same border, radius and type as the `<select>` it stands in for, so a form
   holding one beside a dropdown does not look like two products. It restates
   those numbers rather than leaning on `.ops-touch`: `.ops-touch`'s 44px
   floor and this class are both (0,1,0), this sheet loads after `ops.css`, so
   whichever of the two came last would silently win. Stated here, it is a
   decision; left to source order, it is an accident.

   THE CHEVRON IS A GLYPH THAT CHANGES SHAPE, not a colour that changes value
   (SKILL.md §0: three signals, never colour alone) — ▾ closed, ▴ open, beside
   a row whose radius also squares off against the open body. Any one of the
   three carries it.

   THE LIST IS NOT A NESTED SCROLLER ON A PHONE, and that is a rule rather
   than an oversight: `.ops-check-list`'s own comment records why field 9 lost
   its `<select multiple size="6">` — a scroller inside a 4,260px page eats
   the drag that was scrolling the page. At ≤959px the list is as long as the
   roster and the PAGE scrolls, which is the one scroll a reader already has.
   Above 960 a mouse has a wheel and a cursor that knows which box it is over,
   so the list is capped at about ten rows to keep the form's own shape.

   NO COLOUR LITERALS: `tests/test_design_tokens.py` reads this file's raw hex
   as a ceiling AND a floor, and it is zero. */
.multi-pick-summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  min-height: 36px;
  padding: 0.35rem 0.75rem;
  font-size: 0.875rem;
  line-height: 1.2;
  color: var(--ink);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 6px;
  /* Both halves of hiding the browser's own triangle — `list-style` is what
     Firefox and Chrome read now, the pseudo-element is what older WebKit
     reads, and thirteen copies of the second one is how the census found
     thirteen ways to draw a summary. */
  list-style: none;
}
.multi-pick-summary::-webkit-details-marker { display: none; }
.multi-pick-summary::after {
  content: "▾";
  flex: 0 0 auto;
  color: var(--muted);
}
.multi-pick-box[open] > .multi-pick-summary::after { content: "▴"; }
.multi-pick-box[open] > .multi-pick-summary {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}
/* The in-system focus ring (SKILL.md §4a): `2px solid var(--accent)` with a
   2px offset. Never `outline: none` — the three rules that do are named as
   debt in the skill, not as precedent. */
.multi-pick-summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* ONE LINE, WHATEVER THE ANSWER IS. "Ana, Bo and 4 more" is written to fit;
   a `noneText` or a set of long names still must not make the control two
   rows tall, because the row is measured at 36/44 like every other control. */
.multi-pick-answer {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.multi-pick-body {
  padding: 0.35rem 0.75rem 0.5rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-top: 0;
  border-radius: 0 0 6px 6px;
}
/* The filter box `/js/multi-pick.js` inserts past seven rows. Outside the
   list, so it does not scroll away from the rows it is narrowing. */
.multi-pick-filter { display: block; margin: 0.35rem 0; }
.multi-pick-filter > input { width: 100%; }
/* THE FILTER BOX WEARS THE HOUSE RING (browser walk, 2026-09-20). It was
   the one focusable thing in this component left on Chrome's own
   `outline: auto` — browser blue, directly under a closed row ringed in
   `--accent`, so opening the list changed the colour of "you are here". */
.multi-pick-filter > input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.multi-pick-empty { margin: 0.35rem 0 0; }
/* AND `hidden` HAS TO WIN OVER THE ROW'S OWN DISPLAY, which is the whole
   filter working or not working. The script hides a non-matching row with the
   `hidden` attribute — never by removing it, and never by unticking it, since
   a name filtered off the screen is still an answer — and
   `label.field-check:has(> input[type="checkbox"], …)` is a (0,2,2)
   `display: flex` a few hundred lines up, which beats the browser's own
   `[hidden] { display: none }` outright. That is exactly the defect
   `.btn[hidden]` fixes in this file (the torch toggle and the Scan button
   both showed themselves), and `!important` is needed for the same reason it
   is there: a plain `.multi-pick-list > label[hidden]` is (0,2,1) and LOSES
   on specificity, so it would be a rule that renders nothing. */
.multi-pick-list > label[hidden] { display: none !important; }
/* The inline `status` a cap is refused in, under the control rather than
   beside it: `.form-status` is an inline span with a left margin, written for
   a sentence that sits next to a field, and this one belongs under a
   full-width row. */
.multi-pick > .form-status {
  display: block;
  margin: 0.3rem 0 0;
}
@media (max-width: 959px) {
  .multi-pick-summary {
    min-height: 44px;
    padding: 0.5rem 0.9rem;
    /* 1rem for the same reason the base control rule gives: 16px is the
       threshold below which iOS Safari zooms the page. */
    font-size: 1rem;
  }
}
@media (min-width: 960px) {
  .multi-pick-list { max-height: 15rem; overflow-y: auto; }
}


/* ===== picker (form controls round, 2026-09-20) ===== */
/* `web/views/components/picker/macro.njk` + `web/public/js/picker.js`. Read
   either for the why; this is only what the enhanced control needs on screen.
   Nothing here renders until the script has run — with it blocked the control
   is a plain `<select>` and every rule below matches nothing.

   THE WHOLE BLOCK IS SELF-CONTAINED ON PURPOSE. Two other components landed
   in this round from two other worktrees, each appending its own block to the
   end of this file, so nothing here edits a rule above it and the merge is a
   union.

   THE STACK, as `picker.js` builds it:

     span.picker            > input.picker-input[role=combobox]
                            > select.picker-select   (clipped; what posts)
                            > span.picker-list[role=listbox]
                              > span.picker-group[role=group]
                                > span.picker-group-name
                                > span.picker-row[role=option]
                              > span.picker-row
                              > span.picker-empty */
.picker {
  position: relative;
  display: block;
}
/* The box a reader types in is an ordinary `<input type="text">`, so the base
   control rules at the top of this file already give it its 36/44px height,
   its border, its radius and its font. All it needs is to fill the label. */
.picker-input {
  display: block;
  width: 100%;
}
/* THE SELECT STAYS, CLIPPED. It is the thing that posts and the thing the
   script reads back, so it cannot be `display: none`-d away; `.visually-
   hidden`'s own technique (ops.css:608) takes it off the screen without
   taking it out of the form.

   `select.picker-select`, NOT `.picker-select`. `.ops-info-form select`
   (0,1,1) sets `display: block; width: 100%; padding; border` — a bare class
   here is (0,1,0) and loses to it. The type selector makes this (0,1,1) too,
   and this sheet is linked AFTER ops.css, so it wins on order. `min-height`
   is the same story against the base `select` rule at the top of this file.
   Clipped to nothing either way, but a rule that loses is a rule that will
   be wrong the day somebody changes the other one. */
select.picker-select {
  position: absolute;
  width: 1px;
  height: 1px;
  min-height: 0;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
  pointer-events: none;
}
/* ABOVE WHAT FOLLOWS IT, AND OUT OF THE FLOW. A form grid puts two fields on
   a row and a dozen rows down the page: a list that took up space would push
   every field under it down and back up again as the reader types, and one
   that did not paint over its neighbour would be read through. `z-index` is
   5 — higher than anything in this sheet (the sticky table header is 3) and
   far below the chrome. */
.picker-list {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 5;
  display: block;
  margin-top: 2px;
  /* FIVE ROWS, THEN IT SCROLLS INSIDE ITSELF. 5 x 36px. A list of four
     hundred customers that opened to its full height would cover the page
     and scroll the document instead of itself. */
  max-height: 180px;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 6px;
  box-shadow: var(--shadow);
}
/* `hidden` has to beat the `display: block` above it — (0,2,0) against
   (0,1,0) — for the same reason `.btn[hidden]` does, three hundred lines up.
   No `!important` needed here: nothing else in this product writes a
   `display` on this class. */
.picker-list[hidden] { display: none; }
.picker-group { display: block; }
/* The customer's name over their own bodies of water. Drawn only when more
   than one group is left in the select (picker.js says why), and sticky so it
   is still on screen when the reader has scrolled into the group. */
.picker-group-name {
  position: sticky;
  top: 0;
  display: block;
  padding: 0.3rem 0.6rem;
  background: var(--surface-sunk);
  border-bottom: 1px solid var(--line);
  color: var(--muted);
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
/* A BLOCK, NOT A FLEX ROW (browser walk, 2026-09-20). It was `display: flex`
   with `align-items: center`, which is the obvious way to centre one line in
   a 36px row — and a flex container makes every run of text BETWEEN two
   `<mark>`s an item of its own and trims the spaces at each item's edges. So
   typing "an" drew "Grant Ansel" as "GrantAnsel": the space belonged to the
   run "t ", and the run lost it. The height comes from the padding instead:
   (row − one line) / 2, top and bottom, in the row's own em. */
.picker-row,
.picker-empty {
  display: block;
  box-sizing: border-box;
  min-height: 36px;
  padding: calc((36px - 1.2em) / 2) 0.6rem;
  font-size: 0.875rem;
  line-height: 1.2;
}
.picker-row { cursor: pointer; }
.picker-empty { color: var(--muted); }
.picker-row:hover { background: var(--bg); }
/* THE ACTIVE ROW IS THE KEYBOARD'S, and it has to be visible at the same time
   as the mouse's hover without the two reading as the same thing. A tint made
   FROM the accent rather than a new literal, so the raw-hex ratchet stays at
   zero for this file and a client theme's accent carries it. */
.picker-row.is-active {
  background: color-mix(in srgb, var(--accent) 14%, var(--panel));
  box-shadow: inset 3px 0 0 var(--accent);
}
/* The answer that is already on the form, when the whole list is open. */
.picker-row.is-chosen { font-weight: 600; }
/* THE MATCHED LETTERS. Weight and ink, NOT a yellow ground: SKILL.md §4 says
   `--brand-accent` is "the mark, used sparingly and never as a background for
   text", and `<mark>`'s user-agent style is exactly that background. The
   element keeps its meaning for a screen reader either way. */
.picker-row mark {
  background: transparent;
  color: var(--ink);
  font-weight: 700;
}
/* The house focus ring (SKILL.md §4a), on the one control a reader can reach:
   `2px solid var(--accent)` at `outline-offset: 2px`, never `outline: none`. */
.picker-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (max-width: 959px) {
  /* 44px rows, and five of them. The base control rules already take the
     input itself to 44px at this width. */
  .picker-row,
  .picker-empty { min-height: 44px; padding-block: calc((44px - 1.2em) / 2); }
  .picker-list { max-height: 220px; }
  /* 16px EXACTLY, and not 1rem. iOS Safari zooms the whole page when a
     focused control's text is under 16px, and a picker is the one control on
     a form a reader is certain to focus. The base rule says `1rem`, which is
     the same 16px until a page changes the root size; this one cannot be
     moved by that. */
  /* `.picker > input.picker-input`, NOT `.picker-input` (found by the
     customer-dropdown builder, 2026-09-20). Every picker in the product sits
     in `.ops-info-form`, whose `input:not([type="checkbox"], [type="radio"])`
     rule is (0,2,1) and declares `font: inherit` — the shorthand carries
     font-size, so a bare class (0,1,0) lost and the box rendered at the
     label's 0.85rem: iOS zooms on focus. This spelling ties that rule and
     wins on order, because components.css is linked after ops.css. The
     styleguide's example frame has no such form around it, which is why the
     first browser check measured 16px and saw nothing wrong. */
  .picker > input.picker-input,
  .multi-pick .multi-pick-filter > input { font-size: 16px; }
}

/* AN OPEN MENU LOOKS OPEN (Ted, 2026-09-24): Display stayed open with nothing
   saying so. While a button-style menu is open its button is pressed — accent
   edge, tinted fill, caret up — and menu_close.js closes it on a click
   elsewhere or Esc. */
.gear-menu > summary::after { content: "▾"; font-size: .75em; margin-left: .3em; }
.gear-menu[open] > summary {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 14%, var(--panel));
  box-shadow: inset 0 0 0 1px var(--accent);
}
.gear-menu[open] > summary::after { content: "▴"; }
