/* components.css — part 2 of 7 of the app stylesheet, and the one part that is NOT a cut of the
   original app.css.

   ========================================================================================
   WHAT THIS IS
   ========================================================================================
   A shared component layer. `theme.css` already does the hard half — light and dark tokens with
   measured contrast ratios — but there was no layer between a token and a screen, so every screen
   wrote its own button, its own pill, its own empty state and its own table. The arithmetic of that:
   108 rules across five files declare `cursor: pointer`, `border-radius` is spelled eight ways
   (4/6/7/8/10/12/50%/999px), and nine separate rules say "an empty list is muted text with some
   padding" at five different paddings and four different sizes.

   It matters more than tidiness because **a module has nowhere else to put a style**. A feature lives
   in `app/modules/<key>/` and owns its `backend.py`, `screen.js` and manifest — but no CSS, so its
   styles go into `screens.css`, which is on the shared-collision list precisely because of that. Two
   changes in two modules are supposed never to touch the same file, and today they both touch that one.
   A component here is what lets the next module ship without editing a shared file at all.

   ========================================================================================
   THE THREE RULES
   ========================================================================================
   1. **This is a DEFAULT LAYER, not an override layer.** It is linked second — after `base.css`,
      before every per-screen part — so every existing rule in `screens.css`, `layout.css`,
      `cards.css` and `panels.css` still wins at equal specificity by source order. The same
      reasoning as the form-control defaults in `base.css` (7.28.2). Nothing here may use
      `!important`, and nothing here may be written at a specificity a screen cannot beat with one
      class. A test pins both.

   2. **Namespaced `.ui-`, and additive only.** Not one existing class is renamed, redefined or
      removed. A screen adopts a component by changing its own markup, one screen per release, so a
      conflict with a parallel branch stays small and legible. A find-replace across `app/static/`
      would collide with every branch open at the time — so there is never one.

   3. **No literal color, ever.** Everything here resolves through a token, so a per-instance brand
      override carries and dark mode is not an afterthought. `theme.css` is the only file allowed to
      spell a color, and `test_no_colour_is_hardcoded_outside_the_brand_overrides` enforces it across
      every part including this one.

   ========================================================================================
   WHAT LIVES HERE AND WHAT DOES NOT
   ========================================================================================
   Here: the non-color tokens (space, radius, control geometry, the type steps) and the components
   built from them. `theme.css` keeps every color, because that file is where a measured contrast
   ratio is recorded next to the value it was measured on.

   Adding a component: it belongs here when two screens would otherwise write it twice. One screen's
   one-off stays in that screen's part — a component layer that absorbs every rule becomes a second
   `screens.css` with a prefix.
*/

/* ==========================================================================================
   TOKENS — the axes theme.css does not cover.

   Every value below is one the app ALREADY USES, counted across the six parts and rounded to the
   step that appears most. This is an inventory, not a redesign: picking a fresh 4/8/16 scale would
   mean a visible change on every screen with no measured defect behind it, which is the same trade
   `test_type_and_targets` declined for the type scale in 6.130.0.
   ========================================================================================== */
:root {
  /* Space. Measured frequency of `gap` across the six parts: 8px x72, 6px x44, 10px x40, 12px x21,
     4px x11, 14px x8. So the scale is 4/6/8/10/12/16 — 10 is in it because the app uses it forty
     times, not because a geometric scale would have chosen it. */
  --ui-space-1: 4px;
  --ui-space-2: 6px;
  --ui-space-3: 8px;
  --ui-space-4: 10px;
  --ui-space-5: 12px;
  --ui-space-6: 16px;

  /* Radius. 8px x78, 6px x36, 999px x27, 12px x18. The 7px (x9) and 10px (x25) stragglers are
     deliberately NOT offered — they exist in the app and are left where they are, but a new
     component has no reason to reach for a fourth step. */
  --ui-radius-sm: 6px;
  --ui-radius: 8px;
  --ui-radius-lg: 12px;
  --ui-radius-pill: 999px;

  /* Type. The steps are the app's existing inventory, which `tests/test_type_and_targets.py` holds
     closed — these are names for sizes already in it, not new sizes. Naming them is what stops the
     next component picking 11px or 12px by coin flip. */
  --ui-text-2xs: 10px;   /* a navigation section's label, uppercase and tracked (7.473.0) */
  --ui-text-xs: 11px;    /* pills, hints, meta lines */
  --ui-text-sm: 12px;    /* secondary text */
  --ui-text: 13px;       /* body */
  --ui-text-md: 14px;    /* emphasized body, small headings */
  --ui-text-lg: 15px;    /* panel and section titles */
  --ui-text-figure: 28px; /* a metric numeral on a tile, read at a glance (the dashboard's size) */
  --ui-text-glyph: 24px;  /* a mark drawn as a character: a dialog's close × (7.444.0) */
  --ui-text-icon: 18px;   /* a character standing for an icon in a round header button: the ☰ (7.470.0) */
  --ui-text-2xs: 10px;   /* a contents list's section labels and notes (7.292.0) */
  /* A rendered document's own scale (7.292.0), moved from the Docs screen, which set it for reading-length
     text: body a half-step above the UI's, and two heading sizes the UI scale has no use for. Code is
     relative, so inline code in a heading scales with the heading. */
  --ui-text-prose: 13.5px;
  --ui-text-prose-h2: 17px;
  --ui-text-prose-h1: 24px;
  --ui-text-code: .88em;

  /* Control geometry. WCAG 2.5.8 sets a 24 CSS-pixel floor and this team works on iPads, so the
     floor is a token rather than a number each component remembers — that is the finding
     `tests/test_type_and_targets.py` was written for (6.130.0), and four controls had to be fixed
     one at a time because nothing declared it centrally. */
  --ui-target: 24px;
  --ui-control-h: 32px;
  /* How tall a `.ui-textbox` grows before it scrolls inside itself: no cap, unless its caller sets one (7.492.0). */
  --ui-textbox-max: none;
  /* The standalone pages' top bar (7.410.0). Its own token because those pages load theme.css and this file
     but not base.css, where the app's z-index ladder lives: a `--z-*` there would resolve to nothing. On those
     pages the bar competes only with the page's own content, and a menu that must open over it (the audit
     page's, at 60) stays above it. */
  --ui-z-pagebar: 1;

  /* Where a header popover sits on a phone (7.247.0). `positionHeaderPopover` (layout.js) sets this on the
     popover element itself, just under whichever button opened it, which overrides this default; the
     default only covers the frame before it runs. Declared here rather than as a `var()` fallback, which
     this layer does not use. */
  --pop-top: 96px;

  /* The accent wash: a faint tint of the accent, for a hover or a selected row.
     THIS TOKEN IS A BUG FIX. Five rules in `cards.css` spelled it as a literal `rgba()` of the
     DEFAULT palette's accent — so on a branded instance (a brand that sets `--accent` to a blue)
     every secondary button hovered the default plum, and in the dark theme a 10% plum over the
     dark `--panel` is very nearly nothing. It slipped past both existing guards: the hardcoded-
     color sweep only matches `#rrggbb`, and the frozen-tint sweep only matches the four `rgba()`
     values the status colors used to have.

     `color-mix` is guarded by `@supports` for the reason theme.css states for `--accent-text`: a
     custom property accepts almost any value at parse time, so an unsupported `color-mix()` is
     substituted and then fails at USE, taking the color with it. The fallback is `--panel-2`, an
     existing surface that is always defined — a duller hover, never a missing one. */
  --ui-wash: var(--panel-2);
  --ui-wash-strong: var(--line);
}

@supports (color: color-mix(in srgb, red 50%, white)) {
  :root {
    --ui-wash: color-mix(in srgb, var(--accent) 12%, transparent);
    --ui-wash-strong: color-mix(in srgb, var(--accent) 22%, transparent);
  }
}

/* ==========================================================================================
   BUTTON — `.ui-btn`

   Geometry is `.btn-sm`'s, to the pixel (7px/13px padding, 13px/600, 8px radius), because that is
   the button 51 call sites already use and a component layer that looked *slightly* different would
   make every partially-migrated screen look broken. What it adds over `.btn-sm` is the wash token,
   a real disabled state, a `min-height` that meets the target floor, and variants that exist rather
   than being invented per screen.
   ========================================================================================== */
.ui-btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--ui-space-2);
  padding: 7px 13px;
  min-height: var(--ui-control-h);
  font: inherit; font-size: var(--ui-text); font-weight: 600; line-height: 1;
  border-radius: var(--ui-radius);
  border: 1px solid var(--accent-text);
  background: transparent;
  color: var(--accent-text);
  text-decoration: none;
  cursor: pointer;
  box-sizing: border-box;
  white-space: nowrap;
  /* Background and border only. No transform and no shadow on hover: this app is dense, and a button
     that moves shifts the row it is in. */
  transition: background-color .12s ease, border-color .12s ease;
}

.ui-btn:hover:not(:disabled):not([aria-disabled="true"]) { background: var(--ui-wash); }
.ui-btn:active:not(:disabled):not([aria-disabled="true"]) { background: var(--ui-wash-strong); }

/* `aria-disabled` as well as `disabled`, because a disabled button is not focusable and so cannot
   explain itself — several controls in this app stay focusable and carry a title instead. */
.ui-btn:disabled,
.ui-btn[aria-disabled="true"] { opacity: .5; cursor: default; }

/* The filled variant. `--accent` is the fill token (white on it measures 4.98 in dark, and the light
   palette had zero failures); `--accent-text` is the one for accent-as-ink, which is why the outline
   variants above use that and this one does not. theme.css explains why one token cannot serve both. */
.ui-btn--primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.ui-btn--primary:hover:not(:disabled):not([aria-disabled="true"]) {
  background: var(--accent-press); border-color: var(--accent-press);
}
.ui-btn--primary:active:not(:disabled):not([aria-disabled="true"]) { background: var(--accent-press); }

/* Destructive. Outline rather than filled: a filled red button is the most visually dominant thing on
   a screen, and in this app the destructive action is almost never the primary one. */
.ui-btn--danger { color: var(--err); border-color: var(--err); }
.ui-btn--danger:hover:not(:disabled):not([aria-disabled="true"]) {
  background: color-mix(in srgb, var(--err) 12%, transparent);
}

/* Tertiary: no border. For a control that sits in a row of them, where six outlines read as a fence. */
.ui-btn--ghost { border-color: transparent; color: var(--muted); }
.ui-btn--ghost:hover:not(:disabled):not([aria-disabled="true"]) { color: var(--text); background: var(--ui-wash); }

/* The small size still meets the 24px floor — that is the whole reason it is a token and not a
   number typed here. */
.ui-btn--sm { padding: 3px 9px; font-size: var(--ui-text-sm); min-height: var(--ui-target); }

/* An icon-only button has no text to give it width, so it gets an explicit square. */
/* `flex-shrink: 0` (7.488.0): a square that a flex row may squeeze stops being a square, and the bell's dismiss measured
   23.2px wide beside a long notification, under the 24px floor. */
.ui-btn--icon { padding: 0; width: var(--ui-control-h); height: var(--ui-control-h); min-height: var(--ui-control-h);
  flex-shrink: 0; }
/* `min-height` too (7.488.0): `.ui-btn--icon`'s 32px floor came later in the file at the same weight and won, so every
   small icon button measured 24x32 rather than the 24x24 it states. 24 is still the target floor. */
.ui-btn--sm.ui-btn--icon { width: var(--ui-target); height: var(--ui-target); min-height: var(--ui-target); }

/* A round icon button in the top bar (7.397.0): the notification bell and the Settings gear, which drew the same
   36px circle in two files. Larger than `.ui-btn--icon` because it sits in the header's row, not a form's. Muted
   until hover, as the gear's own rule said it was modeled to be. `position: relative` so a badge can be pinned
   to its corner, which the bell does with its count. */
.ui-icon-btn {
  position: relative; display: inline-flex; align-items: center; justify-content: center;
  width: 36px; height: 36px; padding: 0; border-radius: 50%;
  background: transparent; color: var(--muted); border: 1px solid var(--line); cursor: pointer;
}
.ui-icon-btn:hover { color: var(--brand); border-color: var(--brand-bright); }
/* The button for the screen that is open, as the router marks it: the gear while Settings is up. */
.ui-icon-btn.active { color: var(--on-accent); background: var(--accent); border-color: var(--accent); }
/* Open (7.470.0): a button whose popover or drawer is showing holds its hover look, so it says what it opened. The
   menu toggle and the bell set `aria-expanded`; the gear opens a screen and is marked `.active` instead. */
.ui-icon-btn[aria-expanded="true"] { color: var(--brand); border-color: var(--brand-bright); }
/* The button that opens the navigation (7.470.0, the ☰): text ink and a larger glyph, because it is the rail's only
   control, and on a phone the only way in. Muted, it was the faintest thing in the bar. */
.ui-icon-btn--strong { color: var(--text); font-size: var(--ui-text-icon); }
.ui-icon-btn[hidden] { display: none; }

/* A quiet pill button in the top bar (7.398.0): Report a bug and Help, which shared a class, and the security
   status, which drew the same pill with a light and a count inside. Muted text until hover, so it is there when
   wanted and does not compete with the work. It lays out what it holds in a row, which is how the status pill
   sets its light beside its count. */
.ui-chip-btn {
  display: inline-flex; align-items: center; gap: var(--ui-space-2);
  padding: 4px 10px; border-radius: var(--ui-radius-pill);
  font: inherit; font-size: var(--ui-text-sm); font-weight: 600;
  background: transparent; color: var(--muted); border: 1px solid var(--line); cursor: pointer;
  /* The floor, declared (7.449.0). Its padding already makes it 28px, but the Plan buttons that moved onto it
     each declared the 24px floor, and an undeclared one is one padding change from going. */
  min-height: var(--ui-target);
}
/* The hover sets its own background (7.449.0). The app's original default button (`button:hover` in cards.css) fills
   on hover with --accent-press, and a hover here that set only color and border let that fill through: dark text on a
   dark fill, 1.29:1 in the light theme, on every chip in the header. The wash is the one `.ui-btn` uses. */
.ui-chip-btn:hover { color: var(--text); border-color: var(--accent); background: var(--ui-wash); }
.ui-chip-btn[hidden] { display: none; }

/* The top bar of a standalone server-rendered page (7.410.0): /changelog and /audit drew the same one in two
   files, rule for rule. The brand fill, a way back to the dashboard on the left and the version on the right,
   held at the top while the page scrolls under it. */
.ui-pagebar {
  position: sticky; top: 0; z-index: var(--ui-z-pagebar);
  display: flex; align-items: center; justify-content: space-between; gap: var(--ui-space-5);
  padding: 14px 20px;
  background: var(--brand); color: var(--on-brand); box-shadow: var(--shadow-bar);
}
.ui-pagebar__back { color: var(--on-brand); text-decoration: none; font-weight: 600; font-size: var(--ui-text-md); }
.ui-pagebar__back:hover { text-decoration: underline; }
.ui-pagebar__meta { font-size: var(--ui-text-sm); opacity: .85; letter-spacing: .02em; }
.ui-pagebar[hidden] { display: none; }

/* A row of buttons. Wraps, because a toolbar that overflows on an iPad is the failure this app
   actually sees. */
.ui-btn-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-3); }
/* Push everything after this to the right, without the row needing `space-between` — which is the
   trap `.pl-colhead` fell into in 6.106.1, where an absolutely positioned child landed on top of the
   thing `space-between` had already pinned right. */
.ui-btn-row > .ui-push { margin-left: auto; }

/* The hidden-attribute rule: an author `display` beats the UA's `[hidden]`, so every
   component here that sets one reasserts it. */
/* `--link` (7.292.0): an action that reads as a link — the Docs contents' guide titles, which open a page
   in place and so are buttons, not anchors. Keeps the button's 24px floor, because this is a whole
   screen's navigation (the Docs index measured 22.4px before 7.112.0). */
.ui-btn--link { padding: 0; min-height: var(--ui-target); border: 0; background: none; color: var(--link);
  justify-content: flex-start; text-align: left; line-height: 1.3; }
.ui-btn--link:hover { text-decoration: underline; background: none; }
.ui-btn[hidden], .ui-btn-row[hidden] { display: none; }

/* ==========================================================================================
   PILL — `.ui-pill`

   `.pill` (cards.css, 60 call sites) is the same idea and is left exactly as it is. This is its
   successor for new markup: same geometry, but the status variants are named for the SEMANTIC token
   rather than for the screen — `.pill.vc-ok`, `.pill.it-active` and `.pill.s2-done` are three names
   in three files for one green pill.
   ========================================================================================== */
.ui-pill {
  display: inline-flex; align-items: center; gap: var(--ui-space-1);
  padding: 1px 7px;
  font-size: var(--ui-text-xs);
  border-radius: var(--ui-radius-pill);
  border: 1px solid var(--line);
  color: var(--muted);
  background: transparent;
  /* A pill is an atom: a two-word label ("In Progress") breaking across lines splits the border down
     the middle. The reason is cards.css's, and it is still the reason. */
  white-space: nowrap;
}

.ui-pill--ok     { color: var(--ok);          border-color: var(--ok); }
.ui-pill--warn   { color: var(--warn);        border-color: var(--warn); }
.ui-pill--err    { color: var(--err);         border-color: var(--err); }
.ui-pill--info   { color: var(--link);        border-color: var(--link); }
.ui-pill--accent { color: var(--accent-text); border-color: var(--accent-text); }

/* The filled form, for the one pill on a row that is the current state. Each carries its own
   foreground so the pair's contrast does not depend on the surface behind it — the argument
   theme.css makes for the priority badge. */
.ui-pill--solid { border-color: transparent; color: var(--on-tone); background: var(--muted); }
.ui-pill--solid.ui-pill--ok     { background: var(--ok); }
.ui-pill--solid.ui-pill--warn   { background: var(--warn); }
.ui-pill--solid.ui-pill--err    { background: var(--err); }
/* The tones take --on-tone, which is dark in the dark theme where they are light (7.308.0); accent keeps
   --on-accent, because --accent is a fill in both palettes and white measures 4.98 on the dark one. */
.ui-pill--solid.ui-pill--accent { background: var(--accent); color: var(--on-accent); }

/* Derived, not a second value: a client's contract number on a card is the same pill with a
   different status, and a tint that froze a copy of the token would drift from it the moment the
   token moved — which is what `test_no_tint_freezes_a_copy_of_a_status_colour` exists to stop. */
.ui-pill--soft { border-color: transparent; background: var(--panel-2); }
/* The tone as TEXT on its own tint loses contrast (7.422.0): measured in light at 11px over panel, panel-2 and bg,
   ok, warn and err fell to 3.82-4.51 against the 4.5 small text needs, and a lighter tint cannot fix it on
   panel-2 without the fill disappearing. So the text is the tone moved a fifth of the way toward the text
   color: light worst case 4.87, dark 5.93. Accent already clears it (light 6.53, dark 4.71) and is unchanged. */
@supports (color: color-mix(in srgb, red 50%, white)) {
  .ui-pill--soft.ui-pill--ok     { background: color-mix(in srgb, var(--ok) 15%, transparent);
                                   color: color-mix(in srgb, var(--ok) 80%, var(--text)); }
  .ui-pill--soft.ui-pill--warn   { background: color-mix(in srgb, var(--warn) 15%, transparent);
                                   color: color-mix(in srgb, var(--warn) 80%, var(--text)); }
  .ui-pill--soft.ui-pill--err    { background: color-mix(in srgb, var(--err) 15%, transparent);
                                   color: color-mix(in srgb, var(--err) 80%, var(--text)); }
  .ui-pill--soft.ui-pill--accent { background: color-mix(in srgb, var(--accent) 15%, transparent); }
}

/* Dashed says "this value is real but it came from somewhere else" — an inherited client, a
   tag-derived ticket link. Lifted from `.pill.cust-inh` (6.92.2), which is the only place the app
   had said it. */
.ui-pill--derived { border-style: dashed; }

.ui-pill[hidden] { display: none; }

/* A count in a pill: how many of something wait (7.396.0). Two callers, the notification bell's unread count
   and the Tickets rail item's open count, which drew the same shape at two sizes in two files. The size is the
   rail's, so a one-digit count is a 20px chip on an 18px line. Where it sits is the caller's: the bell pins it
   to its corner, the rail floats it to the item's end. The tone says what kind of count it is. */
.ui-badge {
  display: inline-block; min-width: 20px; padding: 0 6px;
  border-radius: var(--ui-radius-pill);
  font-size: var(--ui-text-xs); font-weight: 700; line-height: 18px; text-align: center; white-space: nowrap;
}
/* Something has gone wrong or needs reading: the bell. `--on-tone` is the theme's paired ink for a status
   fill, dark on the dark theme's light red, where white measured 2.27:1. */
.ui-badge--err   { background: var(--err); color: var(--on-tone); }
/* Work waiting in a list: the rail. */
.ui-badge--brand { background: var(--brand-bright); color: var(--on-brand-bright); }
.ui-badge[hidden] { display: none; }

/* ==========================================================================================
   FIELD — `.ui-field`

   A labeled control. `base.css` (7.28.2) already paints the control itself; what was still missing
   is the row around it — the label, the hint under it, the error. Without one, a screen either omits
   the label (the defect 6.129.0 fixed on the card fields, where a placeholder was the only name and
   it vanished the moment anyone typed) or invents a layout.
   ========================================================================================== */
.ui-field { display: flex; flex-direction: column; gap: var(--ui-space-1); min-width: 0; }

/* A real `<label for>`, not a span. 6.129.0: the tags field had a visible label as a `<span>`, so
   nothing associated it with the input and clicking it did nothing. */
.ui-field__label { font-size: var(--ui-text-sm); font-weight: 600; color: var(--text); }
.ui-field__label .ui-req { color: var(--err); margin-left: 2px; }

/* The hint sits UNDER the control, not in the placeholder, for the 6.129.0 reason. */
.ui-field__hint { font-size: var(--ui-text-xs); color: var(--muted); line-height: 1.45; }

/* An error is never color-alone — the markup pairs it with text, and this only colors it. */
.ui-field__error { font-size: var(--ui-text-xs); color: var(--err); line-height: 1.45; }
.ui-field[data-invalid] :is(input, select, textarea) { border-color: var(--err); }

/* STATUS — the line a form writes the outcome of an action into (7.295.0): "Environment added.",
   "Connection OK", or the error that stopped it. Tone is `data-tone="ok|err"`, absent for a neutral
   message, so the script sets one attribute and never rebuilds a class list. The markup gives it
   `role="status"` so the words are announced; color only reinforces them. */
.ui-status { margin: 0; font-size: var(--ui-text-md); line-height: 1.45; color: var(--muted); }
.ui-status[data-tone="ok"] { color: var(--ok); }
.ui-status[data-tone="err"] { color: var(--err); }
/* Something to look at that is not a failure (7.300.0): "2 settings do not match this board". */
.ui-status[data-tone="warn"] { color: var(--warn); }
/* A running total the form computes as you type (7.404.0): "This contract bills $40,000.00 each period it is
   due." It is the figure the form is about, so it is semibold where a message is regular, at the small size,
   and sits 6px under the rows it totals. It keeps the status line's muted color, as the contract editor's
   total has always rendered. */
/* `--inline` (7.494.0): a short message beside the control it reports on, in a row rather than under it: a tag save,
   a client save, a priority save, "Owned by you", an inherited client. 12px, and no margin of its own, because the
   row it sits in (a `.ui-btn-row`, or a field's own flex row) sets the space. `data-tone` colors it as it does the
   block status. */
.ui-status--inline { display: inline; font-size: var(--ui-text-sm); line-height: inherit; }
.ui-status--figure { margin-top: var(--ui-space-2); font-size: var(--ui-text-sm); font-weight: 600; line-height: 1.5; }

/* ROWS — a list of editable rows, each a few controls and a remove button (7.299.0).

     .ui-rows
     .ui-rows--tight            optional: rows 6px apart rather than 12px (7.377.0)
       .ui-rows__row            one entry; its controls share the width, and wrap on a phone
         input, select          each grows; `.ui-rows__wide` takes twice the share
         .ui-rows__qty          a fixed 90px: a quantity (7.377.0)
         .ui-rows__amount       a fixed 120px: a price, a rate, an amount (7.377.0)
         .ui-rows__date         a fixed 150px: a date (7.377.0)
         .ui-btn--icon          the row's remove, kept at its own size

   Company Links' labeled URLs are the first caller; a contract's billed line items are the same shape and
   the obvious second. The controls here carry no visible label per row, because a label repeated on every
   row is noise, so the caller gives each one an `aria-label` that says which row it belongs to
   ("Link 2 URL"). A field with a visible label belongs in `.ui-field` instead. */
/* Rows sit further apart (12px) than a row's own controls (8px), so on a phone, where a row wraps onto two
   lines, each entry still reads as one unit and not as a run of unrelated fields. */
.ui-rows { display: flex; flex-direction: column; gap: var(--ui-space-5); min-width: 0; }
.ui-rows__row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-3); min-width: 0; }
.ui-rows__row > :is(input, select) { flex: 1 1 160px; min-width: 0; }
.ui-rows__row > .ui-rows__wide { flex: 2 1 240px; }
.ui-rows__row > .ui-btn { flex: 0 0 auto; }
/* A list whose rows each fit on one line at desktop (7.377.0): a contract's rate card, milestones and billed items.
   They sit 6px apart, as those editors always have, and their number and date fields keep a fixed width while the
   one text field takes the rest, so a quantity box is never as wide as the description beside it. */
.ui-rows--tight { gap: var(--ui-space-2); }
.ui-rows__row > .ui-rows__qty { flex: 0 0 90px; }
.ui-rows__row > .ui-rows__amount { flex: 0 0 120px; }
.ui-rows__row > .ui-rows__date { flex: 0 0 150px; }
.ui-rows[hidden], .ui-rows__row[hidden] { display: none; }

/* Side-by-side fields. `auto-fit` rather than a fixed column count so the same markup collapses to
   one column on a phone without a media query per screen. */
.ui-field-grid {
  display: grid; gap: var(--ui-space-5);
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* A field that should take the whole width whatever the grid decided — a textarea, usually. */
.ui-field--wide { grid-column: 1 / -1; }

/* A checkbox or radio reads label-after-control, which is the opposite of every other field.
   **The LABEL carries the target floor, not the box.** Measured while migrating the mileage screen: a
   native checkbox renders 13x13 whatever the stylesheet says, and its `<label for>` toggles it — so the
   label is what a finger actually has to hit, and it was 54x18, under WCAG 2.5.8's 24px. Not a
   regression from the migration; the row it replaced was the same 13px box beside the same short text,
   and nothing had measured either. Fixing it here rather than on the screen is the point of having a
   layer: the next screen that needs a checkbox gets a reachable one without anybody remembering.
   The row takes the floor too, so a label short enough to be narrow still sits in a 24px row. */
.ui-field--inline { flex-direction: row; align-items: center; gap: var(--ui-space-3);
  min-height: var(--ui-target); }
.ui-field--inline .ui-field__label {
  font-weight: 400;
  /* inline-flex, because `min-height` does nothing to a non-replaced inline element — the same trap
     `tests/test_type_and_targets.py` records for the four links it enlarged in 6.130.0, where the
     declaration was present and inert. */
  display: inline-flex; align-items: center; min-height: var(--ui-target); cursor: pointer;
}

.ui-field[hidden], .ui-field-grid[hidden] { display: none; }

/* ==========================================================================================
   TABLE — `.ui-table`

   One table look. The app has several and they disagree about padding, header weight and whether
   rows separate at all — which is visible on any screen that shows two of them.
   ========================================================================================== */
.ui-table { width: 100%; border-collapse: collapse; font-size: var(--ui-text); }

.ui-table th,
.ui-table td { padding: var(--ui-space-2) var(--ui-space-3); text-align: left; vertical-align: top; }

/* The header is separated by a line, not by a fill: a filled header band inside a panel that already
   has a background reads as a second panel. */
.ui-table thead th {
  font-size: var(--ui-text-sm); font-weight: 600; color: var(--muted);
  border-bottom: 1px solid var(--line);
  white-space: nowrap;
}

/* A hairline between rows rather than zebra striping. Striping needs a second surface token that
   survives both themes, and on a panel that is already `--panel-2` there is no third step below it
   that stays legible. */
.ui-table tbody tr + tr td { border-top: 1px solid var(--line); }

/* Only when the row is interactive. A hover on a table nobody can click is a promise the app does
   not keep. */
.ui-table--rows tbody tr:hover td { background: var(--ui-wash); }
.ui-table--rows tbody tr { cursor: pointer; }

/* Figures line up only if the glyphs are the same width. This is the single most common table defect
   in the app: a column of hours where 1 is narrower than 8. */
.ui-table .ui-num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* A tag, an env var or a type name (7.300.0): the chip the Connections tables drew for themselves, so a
   name that must be typed exactly is set apart from the prose around it. In a table, a muted line and a
   section intro, which is where these names appear. `nowrap`, because a tag broken across two lines
   (a ticket-link tag split before its number, measured at 390px) is no longer something anyone can copy or recognize.
   `:where`, so it is a default at element specificity: any class rule wins, which keeps a module's own
   chip (`.sow-tag` in a contracts table) and `.ui-prose code` exactly as they were. */
:where(.ui-table, .ui-muted, .ui-section-head__intro) code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--ui-text-code);
  background: var(--panel-2); padding: .05em .35em; border-radius: 4px; white-space: nowrap; }
.ui-table th.ui-num { text-align: right; }

/* A column that takes only the width its content needs (7.298.0): a row's actions. Without it an auto-layout
   table splits spare width between columns, and the Clients list's buttons floated mid-row, a long way from
   the name they act on. The buttons stay on one line and sit at the right edge; the other column wraps. */
.ui-table .ui-table__fit { width: 1%; white-space: nowrap; }
.ui-table__fit > .ui-btn-row { flex-wrap: nowrap; justify-content: flex-end; }

/* A table in a panel scrolls sideways rather than forcing the panel to. Wrap the table in this.
   `position: relative` makes the wrapper the containing block of anything absolute inside it (7.314.1). A
   scroller clips an absolute descendant only when it is on that descendant's containing-block chain, and a
   `.ui-sr-only` label has no offsets, so it renders where it sits in the table. In a right-hand column of a
   table wider than the wrapper, that label escaped the scroll and widened the whole page: 194px at 390 wide on
   the Azure Inventory catalog, with its service cards open. It adds no stacking context, and a sticky header
   already sticks to this scroller. */
.ui-table-scroll { position: relative; overflow-x: auto; }

.ui-table[hidden] { display: none; }

/* ==========================================================================================
   PANEL — `.ui-panel`

   A titled box. Every screen has one and every screen draws it differently.
   ========================================================================================== */
.ui-panel {
  display: flex; flex-direction: column;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--ui-radius-lg);
  box-shadow: var(--shadow);
  min-width: 0;
}

.ui-panel__head {
  display: flex; align-items: center; gap: var(--ui-space-3);
  padding: var(--ui-space-4) var(--ui-space-5);
  border-bottom: 1px solid var(--line);
}
/* The title takes the slack and the actions keep their size — the rule 7.28.4 arrived at for the
   calendar block, where the subject must lose characters before the only button does. */
.ui-panel__title { flex: 1 1 auto; min-width: 0; font-size: var(--ui-text-lg); font-weight: 600; margin: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ui-panel__acts { flex: 0 0 auto; display: flex; align-items: center; gap: var(--ui-space-2); }
/* A panel's own close × (7.461.0: the side panels' hide), at the dialog ×'s 24px regular glyph in its 32px box. */
.ui-panel__acts > .ui-btn--icon { font-size: var(--ui-text-glyph); font-weight: 400; }

.ui-panel__body { padding: var(--ui-space-5); min-width: 0; }
.ui-panel__foot {
  padding: var(--ui-space-4) var(--ui-space-5);
  border-top: 1px solid var(--line);
  display: flex; align-items: center; gap: var(--ui-space-3);
}

/* A panel of prose somebody proofreads (7.347.0): capped and centered, because a 2,000px line is not
   proofreadable. The investigation panel's draft comment goes into a client's security console as written. */
.ui-panel--readable { max-width: 68rem; margin-inline: auto; width: 100%; }

/* A panel holding a table wants the table flush to its edges, and the head's own padding to survive. */
.ui-panel--flush .ui-panel__body { padding: 0; }
.ui-panel--flush .ui-table th:first-child,
.ui-panel--flush .ui-table td:first-child { padding-left: var(--ui-space-5); }
.ui-panel--flush .ui-table th:last-child,
.ui-panel--flush .ui-table td:last-child { padding-right: var(--ui-space-5); }

/* A title's own qualifier — "across all your orgs", a date range — in the title's line but not its weight
   (7.293.0). Inside the title, so it truncates with it before an action does. */
.ui-panel__sub { font-weight: 400; font-size: var(--ui-text-sm); color: var(--muted); }
.ui-panel[hidden], .ui-panel__head[hidden], .ui-panel__foot[hidden], .ui-panel__acts[hidden] { display: none; }

/* ==========================================================================================
   EMPTY STATE — `.ui-empty`

   Nine rules across three files say "muted text with some padding", at five paddings and four sizes.
   More to the point, most of them say only that: a bare "No results" tells somebody nothing about
   whether the list is empty, the filter is too narrow, or the integration is not configured. The
   component has a slot for the sentence that does.
   ========================================================================================== */
.ui-empty {
  display: flex; flex-direction: column; align-items: center; gap: var(--ui-space-2);
  padding: var(--ui-space-6) var(--ui-space-5);
  text-align: center;
  color: var(--muted);
}
.ui-empty__title { font-size: var(--ui-text-md); font-weight: 600; color: var(--text); }
.ui-empty__hint { font-size: var(--ui-text); line-height: 1.55; max-width: 44ch; }
.ui-empty__acts { margin-top: var(--ui-space-2); display: flex; gap: var(--ui-space-3); }

/* The failure case is not the empty case, and reading as one is how a broken integration looks like
   a quiet week. `--err` on the title, and the markup still says so in words. */
.ui-empty--error .ui-empty__title { color: var(--err); }

/* Inside a table cell or a narrow panel, the padded centered block is too much. */
.ui-empty--inline { padding: var(--ui-space-4) 0; align-items: flex-start; text-align: left; }

.ui-empty[hidden] { display: none; }

/* ==========================================================================================
   TOOLBAR — `.ui-toolbar`

   The filter row above a list. Every screen has one; they disagree about gap and about what happens
   at phone width, and several of them overflow rather than wrapping.
   ========================================================================================== */
.ui-toolbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-3);
  margin-bottom: var(--ui-space-4);
}
/* A toolbar of LABELED fields aligns on the controls, not on the center. `.ui-field` stacks its label
   above its control, so a center-aligned row floats the buttons halfway up the labels — measured while
   migrating the mileage screen, whose own toolbar rule had `align-items: flex-end` for exactly this
   and was the only place in the app that knew. (Naming that retired class here as a selector is enough
   to make the dead-class scan think a rule still exists, so it is described rather than spelled.) It is a modifier rather than the default because a
   toolbar of bare controls (a search box and two buttons) does want the center. */
.ui-toolbar--fields { align-items: flex-end; }
.ui-toolbar__grow { flex: 1 1 200px; min-width: 0; }
.ui-toolbar__end { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-3); }
.ui-toolbar[hidden] { display: none; }

/* A compact control for a dense row: a filter above a table column, a scope picker in a toolbar.
   `base.css` paints every text input and select at 13px with 6px/8px padding, which is right for a form
   and too tall for a table header, so the Detections screen wrote three private copies of this
   at two paddings and two radii. It still meets the target floor — the floor is the reason a compact
   control is a token and not a number.
   `input.ui-control--sm` and not the bare class: base.css reaches text inputs through
   `input[type="text"]`, one class and one element, which a lone class loses to whatever the source
   order says. Matching that weight and coming later is what makes the compact size apply at all. */
.ui-control--sm, input.ui-control--sm {
  font-size: var(--ui-text-sm);
  min-height: var(--ui-target);
  padding: 2px 6px;
  border-radius: var(--ui-radius-sm);
}
.ui-control--sm:disabled { opacity: .55; cursor: default; }

/* A slider and the value it is set to (7.497.0): Branding's logo size and the calendar's time scale, which
   had drawn the same row twice. The track takes the room and is the target, since the whole of it is
   draggable, so the floor is on the input. The readout is secondary (it repeats the control's value), and has
   tabular figures over a width floor so the text changing as you drag does not resize the track under the thumb.
   `[hidden]` is re-asserted because the row is `display: flex`. It composes with `.ui-field--inline`, whose row
   it shares in the calendar: the same display, alignment and gap. */
.ui-range { display: flex; align-items: center; gap: var(--ui-space-3); min-width: 0; }
.ui-range[hidden] { display: none; }
.ui-range__input { flex: 1 1 auto; min-width: 0; min-height: var(--ui-target); accent-color: var(--accent); }
.ui-range__out { flex: 0 0 auto; min-width: 3.5em; text-align: right; font-size: var(--ui-text-sm);
  color: var(--muted); font-variant-numeric: tabular-nums; }

/* A segmented control: Day/Week/Month, My/All. Four screens draw this and no two agree.
   The selected one is a fill, and `aria-pressed` / `aria-current` is what selects it — state in the
   markup that a screen reader can read, rather than a class only CSS understands. */
.ui-seg { display: inline-flex; border: 1px solid var(--line); border-radius: var(--ui-radius); overflow: hidden; }
.ui-seg > button {
  appearance: none; border: 0; background: transparent; color: var(--muted);
  font: inherit; font-size: var(--ui-text-sm); font-weight: 600;
  padding: 0 var(--ui-space-4); min-height: var(--ui-target); cursor: pointer;
}
.ui-seg > button + button { border-left: 1px solid var(--line); }
.ui-seg > button:hover:not([aria-pressed="true"]):not([aria-current="true"]) { color: var(--text); background: var(--ui-wash); }
.ui-seg > button[aria-pressed="true"],
.ui-seg > button[aria-current="true"] { background: var(--accent); color: var(--on-accent); }
.ui-seg[hidden] { display: none; }

/* ==========================================================================================
   METER — `.ui-meter`

   A single proportion, drawn as a bar. There are already several of these (the management
   dashboard's `.mgmt-hbar`, the SOC 2 progress rollups) and each is a private pair of divs.

   Deliberately NOT a chart. This app's charts are the Gantt and the dashboard tiles, both of which
   are single-series and get their color from `--accent`; there is no multi-series chart anywhere in
   it, so shipping a categorical series palette here would be inventing a parameter nothing consumes.
   When one arrives, its palette belongs in `theme.css` beside `--cal-*` and `--prio-*`, which are
   the two categorical scales this app already has — and it gets validated against both themes' three
   surfaces the way those were, not chosen by eye.
   ========================================================================================== */
.ui-meter {
  display: block; width: 100%; height: 8px;
  background: var(--panel-2);
  border-radius: var(--ui-radius-pill);
  overflow: hidden;
}
.ui-meter > span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
.ui-meter--ok   > span { background: var(--ok); }
.ui-meter--warn > span { background: var(--warn); }
.ui-meter--err  > span { background: var(--err); }

/* The figure beside it. Tabular, because a meter row is a column of them and a jumping decimal point
   is the thing that makes a set of numbers unreadable. */
.ui-meter-row { display: flex; align-items: center; gap: var(--ui-space-3); }
.ui-meter-row .ui-meter { flex: 1 1 auto; }
/* The name beside a bar, at a fixed width so a column of bars starts at one edge (7.293.0) — the management
   dashboard's hours, which were a 140px grid column for that reason. Truncates; 100px on a phone. */
.ui-meter-row__label { flex: 0 0 140px; min-width: 0; font-size: var(--ui-text); overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 780px) { .ui-meter-row__label { flex-basis: 100px; } }
.ui-meter-row .ui-num { flex: 0 0 auto; font-size: var(--ui-text-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }
.ui-meter[hidden], .ui-meter-row[hidden] { display: none; }

/* ==========================================================================================
   FACTS — `.ui-facts`, a `<dl>` of short labeled values.

   A small uppercase label over its value: "Grouping", "Analysis", "Disposition". The Detections
   stored-report view and the investigation panel each wrote this privately, in two shapes (a `<b>`
   forced to block, and a separate label element), at the same 11px/uppercase/.04-.06em.
   A `<dl>` because that is what it is — a screen reader announces the pairs as pairs.
   ========================================================================================== */
.ui-facts { margin: 0; display: flex; flex-direction: column; gap: var(--ui-space-3); min-width: 0; }
.ui-facts > div { min-width: 0; }
/* `.ui-overline` is the same label standing alone, over a section rather than a value — one rule, so the
   two cannot drift into two uppercase styles. */
.ui-facts dt, .ui-overline {
  font-size: var(--ui-text-xs); font-weight: 700; color: var(--muted);
  text-transform: uppercase; letter-spacing: .05em;
}
.ui-facts dd { margin: 0; font-size: var(--ui-text); line-height: 1.55; overflow-wrap: anywhere; }
/* `--grid` (7.416.0): the pairs side by side, the label column as wide as its longest label. A card's facts in its
   board row and its modal, and the linked ticket's beside them, each wrote this privately as `.cd-details`. Here
   the label is a plain muted line rather than the overline, and the pairs are the `<dl>`'s direct children. The
   7px row gap is the one those grids always had; there is no token at 7. */
.ui-facts--grid { display: grid; grid-template-columns: max-content 1fr; gap: 7px var(--ui-space-6); }
.ui-facts--grid > dt { font-size: var(--ui-text); font-weight: 400; color: var(--muted); text-transform: none;
  letter-spacing: normal; align-self: center; }
.ui-facts--grid > dd { font-size: var(--ui-text-md); line-height: 1.5; overflow-wrap: normal; }
/* A key that is a `<label>` for the value's field fills its cell, so the whole key is the click target, not just
   the word: the card's Tags label measured 68x20 that way and 27x15 inline. */
.ui-facts--grid > dt > label { display: block; }
.ui-facts[hidden] { display: none; }

/* ==========================================================================================
   CODE BLOCK — `.ui-code`, preformatted text that wraps.

   A payload shown for inspection: what was posted to a security console, what was sent to the model. It WRAPS,
   because the thing being checked is usually one very long line of JSON, and a sideways scroll
   inside a panel inside a scrolling page is three scrollbars deep. Three private copies of this
   existed (two in `screens.css`, one in `panels.css`) at three paddings and three radii.
   ========================================================================================== */
.ui-code {
  display: block; margin: var(--ui-space-2) 0 0;
  padding: var(--ui-space-3) var(--ui-space-4);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--ui-text-xs);
  white-space: pre-wrap; overflow-wrap: anywhere;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--ui-radius-sm);
  max-height: 24rem; overflow: auto;
}
.ui-code[hidden] { display: none; }

/* ==========================================================================================
   STACK — `.ui-stack`, vertical rhythm without margins.

   A column of sections with one gap between them. The investigation panel spaced its twenty-odd
   blocks with a margin on each (`10px 0 0`, `12px 0`, `14px 0 5px`, `2px 0 10px`...), so the space
   between two blocks depended on which two they were, and a block that rendered empty still left its
   margin. A gap belongs to the container: it exists only BETWEEN children, and an empty child costs
   nothing if the caller renders `''` for it, which every renderer here does.
   ========================================================================================== */
.ui-stack { display: flex; flex-direction: column; gap: var(--ui-space-5); min-width: 0; }
.ui-stack--tight { gap: var(--ui-space-2); }
/* A paragraph's own margins would double the gap the stack already puts between its children. */
.ui-stack > p { margin: 0; }
.ui-stack[hidden] { display: none; }

/* ==========================================================================================
   CALLOUT — `.ui-callout`, a boxed aside inside a screen.

   History that frames a verdict, an open question, a warning that posting adds a second comment, a
   list of suggested steps. The investigation panel drew six of these privately (`.cs-an-open`,
   `.cs-an-hist`, `.cs-an-inv`, `.cs-an-guide`, `.cs-inv-open`, `.cs-inv-log`) with four borders,
   three radii and two fills, which is why no two of them looked related although all of them were
   the same kind of thing.

   The variant colors the LEFT RULE and nothing else. Text stays `--text` on `--panel-2`, which is the
   7.88.0 rule (`test_no_tinted_fill_is_illegible`): a fill tinted toward the text's own hue loses
   contrast as the tint strengthens, so the meaning goes in the rule and the words, never in the ink.
   ========================================================================================== */
.ui-callout {
  display: flex; flex-direction: column; gap: var(--ui-space-2);
  padding: var(--ui-space-3) var(--ui-space-4);
  background: var(--panel-2);
  border: 1px solid var(--line); border-left: 3px solid var(--line);
  border-radius: var(--ui-radius);
  font-size: var(--ui-text-sm); line-height: 1.5; color: var(--text);
  min-width: 0;
}
.ui-callout--warn   { border-left-color: var(--warn); }
.ui-callout--info   { border-left-color: var(--link); }
.ui-callout--accent { border-left-color: var(--accent); }
/* Something failed (7.298.0): the client migration's "Failed" group. Same rule-only coloring as the rest. */
.ui-callout--err    { border-left-color: var(--err); }
/* A list inside a callout is the common case (steps, questions, sightings); without this it takes the
   UA's 40px indent and 1em margins, which is most of a narrow callout's width. */
.ui-callout ul { margin: 0; padding-left: var(--ui-space-6); display: flex; flex-direction: column; gap: 3px; }
.ui-callout p { margin: 0; }
.ui-callout[hidden] { display: none; }

/* ==========================================================================================
   POPOVER — `.ui-popover`, the dropdown under a header button, and `.ui-popover-item`, its rows.

   The header has three: Detections, the notification bell and Company Links. Each was written
   privately in `screens.css` with the same anchoring (`top: calc(100% + 8px); right: 0`), the same
   62vh cap and the same shadow, at three widths, three paddings and two radii, and `layout.css`
   already had to list all three by name to pin them to the viewport on a phone. All three are here
   now — Detections in 7.247.0, the bell in 7.249.0, Company Links in 7.260.0 — and that list is gone.

   Widths are variants, not a number each screen types, because the three are genuinely different
   sizes: a detection row carries a host, a severity and two buttons; a client name is one word.
   `--wide` is Detections; `--narrow` arrived with Company Links in 7.260.0, which lists one client
   per line and needs no more.
   ========================================================================================== */
.ui-popover {
  position: absolute; top: calc(100% + 8px); right: 0;
  width: 360px; max-width: 92vw; max-height: 62vh; overflow: auto;
  display: flex; flex-direction: column; gap: var(--ui-space-3);
  padding: var(--ui-space-5);
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--ui-radius-lg); box-shadow: var(--shadow);
  z-index: var(--z-header-pop);
}
.ui-popover--wide { width: 420px; }
.ui-popover--narrow { width: 300px; }
.ui-popover__title { font-size: var(--ui-text-md); font-weight: 600; margin: 0; }
.ui-popover__sub { font-size: var(--ui-text-sm); color: var(--muted); line-height: 1.5; margin: 0; }
/* A checkbox row in a popover (7.470.0, the Panels menu): the whole label is the target, at the control height, so a
   finger in a short menu has as much to hit as it had in the 32px rows the private menu drew. */
.ui-popover > .ui-field--inline { min-height: var(--ui-control-h); }
/* On a phone the header's buttons sit at the left of a full-width row, so right-anchoring would fling
   the popover off the left edge. Pinned to the viewport instead; `--pop-top` is set by
   `positionHeaderPopover` (layout.js) to just under whichever button opened it. It lives here rather
   than in `layout.css` so a popover gets it by being one, not by being added to a list. */
@media (max-width: 640px) {
  .ui-popover { position: fixed; top: var(--pop-top); left: 12px; right: 12px; width: auto; max-width: none; }
}

/* A row in a popover. It STACKS — the thing on one full-width line, its actions on a line beneath —
   because a second action beside the text left the text 170px wide in the Detections popover and
   wrapped every field. The link and the buttons are siblings, not nested, because a <button> cannot
   sit inside an <a>. */
.ui-popover-item {
  display: flex; flex-direction: column; gap: var(--ui-space-2);
  padding: var(--ui-space-4) var(--ui-space-5);
  border: 1px solid var(--line); border-radius: var(--ui-radius);
  /* Transparent, which a <div> already is, so a <button> row (the bell's) is the same item without a reset. */
  color: inherit; background: transparent; min-width: 0;
}
.ui-popover-item:hover { border-color: var(--muted); background: var(--panel-2); }
.ui-popover-item__main { display: flex; flex-direction: column; gap: var(--ui-space-1);
  color: inherit; text-decoration: none; min-width: 0; }
.ui-popover-item__title { display: flex; align-items: center; gap: var(--ui-space-3); min-width: 0;
  font-size: var(--ui-text); font-weight: 600; }
.ui-popover-item__meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-1) var(--ui-space-4);
  font-size: var(--ui-text-sm); color: var(--muted); line-height: 1.5; }
.ui-popover-item__acts { display: flex; justify-content: flex-end; gap: var(--ui-space-2); }

.ui-popover[hidden] { display: none; }
.ui-popover-item[hidden] { display: none; }

/* ==========================================================================================
   MENU — `.ui-menu`, a short list of actions opened from a button: a card's ⋮ menu and a crumb's menu (7.423.0).

     .ui-menu                       the box: a min-width, the panel, a 10px radius, the shadow
       button.ui-menu__item         one action; `data-tone="danger"` for a destructive one, and `data-armed`
                                    for a two-click confirm that has had its first click
       .ui-menu__sep                a rule between groups

   Not `.ui-popover`, which is the header's 360px dropdown of bordered cards and pins itself to the viewport on a
   phone. A menu is the BOX AND THE ROWS only: where it opens and what it sits above stay the caller's (the card's
   ⋮ menu under its button, the crumb menu at the crumb), so adopting it cannot move one. Its values are the card
   menu's, which the crumb menu used to draw a slightly different copy of.
   ========================================================================================== */
.ui-menu {
  display: flex; flex-direction: column; gap: 2px; min-width: 178px; padding: 6px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow);
}
.ui-menu__item {
  text-align: left; background: transparent; border: 0; color: var(--text); font: inherit; font-size: var(--ui-text);
  font-weight: 600;
  padding: 8px 10px; border-radius: 7px; cursor: pointer; white-space: nowrap;
}
.ui-menu__item:hover { background: var(--panel-2); }
.ui-menu__item:disabled { opacity: .5; cursor: default; }
.ui-menu__item[data-tone="danger"] { color: var(--err); }
/* `--on-tone`, the ink the solid pills put on a tone fill: dark in the dark theme, where `--err` is light. */
.ui-menu__item[data-tone="danger"][data-armed] { background: var(--err); color: var(--on-tone); }
.ui-menu__sep { height: 1px; background: var(--line); margin: 4px 2px; }
.ui-menu[hidden], .ui-menu__item[hidden] { display: none; }

/* ==========================================================================================
   CRUMBS — `.ui-crumbs`, the trail of a card's ancestors over its title (7.488.0): a board card's and the card
   modal's.

     .ui-crumbs                     the trail: one line of small muted text that wraps between crumbs
       a.ui-crumbs__item            one ancestor; the 24px floor is the box, and a long title ends in an ellipsis
       .ui-crumbs__sep              the `›` between two, hidden from a screen reader, which reads the links as a list

   Its values are the board card's, which the modal already shared. Spacing under the trail stays its caller's (the
   board's 3px, the modal's 2px and one line), because each sits over a different title.
   ========================================================================================== */
.ui-crumbs {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-1);
  font-size: var(--ui-text-sm); line-height: 1.3; color: var(--muted);
}
/* `inline-flex` is what lets `min-height` reach the 24px floor on a link, and `align-items: center` keeps the text
   where it sat. It measured 204x16 before that floor; this team works on iPads. */
.ui-crumbs__item {
  display: inline-flex; align-items: center; min-height: var(--ui-target); box-sizing: border-box;
  padding: var(--ui-space-1); border-radius: 4px; max-width: 60ch;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--muted); text-decoration: none; cursor: pointer;
}
.ui-crumbs__item:hover { color: var(--accent); text-decoration: underline; }
.ui-crumbs__sep { opacity: .55; }
.ui-crumbs[hidden] { display: none; }

/* ==========================================================================================
   NAV — `.ui-nav`, the shell's navigation (7.473.0): the rail.

     .ui-nav
       .ui-nav__group[data-collapsed]   a section; collapsed by the attribute alone
         button.ui-nav__label[aria-expanded][data-has-active]   its header, which is the collapse toggle
           .ui-nav__caret               the CSS triangle, down when open, right when shut
         .ui-nav__item[.active]         a screen button or a link; `.active` is the screen that is open
         .ui-nav__sub[data-collapsed]   a module's submenu inside a section ("Network ▸")
           button.ui-nav__subtoggle     its heading, a rail item that opens
           .ui-nav__subitems            its items, indented
       .ui-nav__foot                    the actions at the bottom: the theme toggle, Log out

   One caller, and that is the exemption, recorded: this is the shell's navigation, and modules are already its
   callers — they inject items and submenus through `app.js` (azure_inventory today) — so it is a contract modules
   build against, not one screen's private list. The panel it sits in (`.railnav`: the 1440 push column, the phone
   drawer) is layout's.

   COLLAPSE IS CSS ONLY, and that is load-bearing: modules insert their item into a group's DOM once at boot,
   positioned against the `[data-order]` items, and nothing re-runs that. A collapse that re-rendered a group would
   delete them; hiding the children leaves the DOM alone. A collapsed group holding the open screen keeps a dot on
   its label (`data-has-active`) rather than opening itself, which would undo the person's choice silently.
   ========================================================================================== */
.ui-nav { display: flex; flex-direction: column; gap: 14px; }
.ui-nav__group { display: flex; flex-direction: column; gap: 2px; }
.ui-nav__group + .ui-nav__group { border-top: 1px solid var(--line); padding-top: var(--ui-space-4); }
.ui-nav__label {
  display: flex; align-items: center; gap: var(--ui-space-2); width: 100%; text-align: left;
  font: inherit; font-size: var(--ui-text-2xs); font-weight: 700; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text); padding: 2px var(--ui-space-4) 5px; min-height: var(--ui-target);
  background: none; border: 0; cursor: pointer;
}
.ui-nav__label:hover { color: var(--accent-text); background: var(--ui-wash); }
.ui-nav__caret {
  margin-left: auto; width: 0; height: 0; flex: none;
  border-left: 4px solid transparent; border-right: 4px solid transparent;
  border-top: 4px solid currentColor; opacity: .55;
  transition: transform .12s ease;
}
.ui-nav__label[aria-expanded="false"] .ui-nav__caret,
.ui-nav__subtoggle[aria-expanded="false"] .ui-nav__caret { transform: rotate(-90deg); }
@media (prefers-reduced-motion: reduce) { .ui-nav__caret { transition: none; } }
.ui-nav__group[data-collapsed] > .ui-nav__item,
.ui-nav__group[data-collapsed] > .ui-nav__sub { display: none; }
.ui-nav__group[data-collapsed] > .ui-nav__label[data-has-active]::before,
.ui-nav__sub[data-collapsed] > .ui-nav__subtoggle[data-has-active]::before {
  content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex: none;
}
/* A submenu reads as a rail item that opens, not as a second section header: same size and padding as an item. */
.ui-nav__sub { display: flex; flex-direction: column; gap: 2px; }
.ui-nav__subtoggle {
  display: flex; align-items: center; gap: var(--ui-space-2); width: 100%; box-sizing: border-box; text-align: left;
  background: transparent; border: 0; border-radius: var(--ui-radius); padding: var(--ui-space-3) var(--ui-space-4);
  min-height: var(--ui-target); font: inherit; font-size: var(--ui-text-md); color: var(--muted); cursor: pointer;
}
.ui-nav__subtoggle:hover { background: var(--panel-2); color: var(--text); }
.ui-nav__subtoggle[data-has-active] { color: var(--text); font-weight: 600; }
.ui-nav__subitems { display: flex; flex-direction: column; gap: 2px; padding-left: var(--ui-space-5); }
.ui-nav__sub[data-collapsed] > .ui-nav__subitems { display: none; }
/* An item lays out a row (an icon, a label, a badge pushed right), so the theme toggle's icon and words need no
   rule of their own. */
.ui-nav__item {
  display: flex; align-items: center; gap: var(--ui-space-3); width: 100%; box-sizing: border-box; text-align: left;
  background: transparent; border: 0; border-radius: var(--ui-radius); padding: var(--ui-space-3) var(--ui-space-4);
  font: inherit; font-size: var(--ui-text-md); color: var(--muted); cursor: pointer; text-decoration: none;
}
.ui-nav__item:hover { background: var(--panel-2); color: var(--text); }
.ui-nav__item.active { background: var(--accent); color: var(--on-accent); }
.ui-nav__item > .ui-badge { margin-left: auto; }
/* On the active item's accent fill, the badge inverts: the ink that sits on accent, around accent text. */
.ui-nav__item.active > .ui-badge { background: var(--on-accent); color: var(--accent); }
.ui-nav__foot {
  margin-top: auto; border-top: 1px solid var(--line); padding-top: var(--ui-space-4);
  display: flex; flex-direction: column; gap: var(--ui-space-1);
}
/* After every display above, so `hidden` wins over each of them. */
.ui-nav[hidden], .ui-nav__group[hidden], .ui-nav__sub[hidden], .ui-nav__item[hidden] { display: none; }

/* ==========================================================================================
   TEXT UTILITIES

   Four, and only four. A utility layer is how a component layer turns back into inline styles, so
   this is the set that earns its place: each replaces a declaration written more than fifty times.
   `color: var(--muted)` alone appears 260 times.
   ========================================================================================== */
.ui-muted { color: var(--muted); }
.ui-num { font-variant-numeric: tabular-nums; }
/* One-line truncation needs three declarations and `min-width: 0` on the flex parent, and the
   missing `min-width` is why a truncated title in a flex row pushes its siblings off instead. */
.ui-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
/* Visible to a screen reader, not on screen. Not `display: none`, which removes it from both. */
.ui-sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ==========================================================================================
   CARD GRID — a tile per entity, when a table would waste the identity.

   Built for `azure_inventory`'s application list (7.201.0): ten to thirty tiles, each a name, an
   environment and a status. Two private cards already existed when this was written — `.ml-card`
   in `screens.css` and `.br-pv-card` in `panels.css` — and a third was about to be, which is the
   "two screens would otherwise write it twice" test the header asks for, met before the fact
   rather than after.

   `auto-fill` rather than a fixed column count: the caller does not know the viewport and should
   not have to declare a breakpoint. `minmax(0, 1fr)` and not `minmax(220px, 1fr)` as the floor —
   below 220px of space a single column is correct, and a hard minimum would overflow the rail
   instead. The 220px lives in the max of the first argument, which is what lets one tile sit at
   its natural width rather than stretching across the row.
   ========================================================================================== */
.ui-card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
  gap: var(--ui-space-5);
  min-width: 0;
}

.ui-card {
  display: flex; flex-direction: column; gap: var(--ui-space-2);
  padding: var(--ui-space-5);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--ui-radius);
  min-width: 0;
}

/* `--raised` (7.488.0): a card that stands on the page by itself rather than as a tile in a grid, the board's work
   items. It keeps what those always had: the larger padding and radius, the shadow, and its contents in normal
   flow, because a work item's sections space themselves. 18px has no token; it is the board card's own, and it held
   at every width (layout.css asked for 14px on a phone, but cards.css loaded later and won). */
.ui-card--raised { display: block; padding: 18px; border-radius: var(--ui-radius-lg); box-shadow: var(--shadow); }

/* A tile that is a link or a button keeps the affordance the element already has; this only adds
   the hover wash, and only when the caller opts in. A whole-tile target is well past the 24px
   floor by construction, so no min-height is declared here. */
.ui-card--action { cursor: pointer; text-align: left; font: inherit; color: inherit; width: 100%;
  text-decoration: none; }
.ui-card--action:hover { background: var(--ui-wash); }
.ui-card--action:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.ui-card__title { font-size: var(--ui-text-md); font-weight: 600; margin: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* `--wrap` (7.287.0): a title that is what people scan — a ticket's subject — wraps rather than ending in an
   ellipsis. The one-line default is right for a tile whose title is a name; it is wrong for a list whose
   titles are sentences, where the part cut off is often the part that says what the ticket is. */
.ui-card__title--wrap { white-space: normal; overflow: visible; text-overflow: clip; overflow-wrap: anywhere; }
/* A tile that leads with a number — a count to act on, read before the words under it (7.242.0). The
   dashboard's overview tiles set that size first; this is the same size, so a tile here and a tile there
   read as the same kind of thing. Tabular, because tiles sit in a row and their digits line up. */
.ui-card__figure { font-size: var(--ui-text-figure); font-weight: 700; line-height: 1.05;
  font-variant-numeric: tabular-nums; margin: 0; }
/* The meta row wraps rather than truncating: an environment and a status are both short and both
   load-bearing, so losing either to an ellipsis is worse than a second line. */
.ui-card__meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2);
  font-size: var(--ui-text-xs); color: var(--muted); }
/* A tile's movement since the last period (7.293.0). Direction, not judgment: more tasks created than
   closed is not automatically bad, so up and down are the palette's own colors rather than success and
   danger. The arrow and the words say it too — `data-dir` carries the state, and a `.ui-sr-only` word
   inside says "up", "down" or "flat" to a screen reader, so the color is never the only signal. */
.ui-card__trend { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--muted); }
.ui-card__trend[data-dir="up"] { color: var(--ok); }
.ui-card__trend[data-dir="down"] { color: var(--warn); }
/* A figure that could not be computed is not zero, and must not look like one (7.293.0): an em dash in the
   muted ink, with the reason in the tile's tooltip. */
.ui-card[data-missing] .ui-card__figure { color: var(--muted); font-weight: 600; }

/* ==========================================================================================
   SECTION HEAD — the start of one section in a long page of sections (7.272.1).

   A rule above, a heading, and an optional intro paragraph. Made for a module's contribution to a
   Settings slot (`data-module-slot="settings:..."` in index.html): the slot deliberately has no heading
   of its own, so each contribution brings one, and until this existed the only way to match the page was
   the private `settings-sep` / `settings-h4` / `settings-intro` trio — which the private-UI guard
   (7.272.0) correctly refuses in a new module. Its first real catch was exactly that.

   **It matches that trio's look to the pixel**, measured against the Contracts contribution in a
   browser: 18px above the rule and 18px below it, a 14px bold heading with 12px under it, a 13px muted
   intro at 1.5 with 14px under it. The two spacings with no token here are written as literals rather
   than rounded to the nearest one, because "matches today" was the requirement.

   No `display` is set, so the bare `hidden` attribute hides it through the UA rule. */
.ui-section-head { border-top: 1px solid var(--line); margin-top: 18px; padding-top: 18px; min-width: 0; }
/* A section head that opens a TAB PANEL has nothing above it to separate from (7.295.0): a Settings tab's
   first section would otherwise draw a rule directly under the tab bar's own border. Scoped to the panel,
   not to any container: a module's Connections contribution opens its own wrapper (`#s2-site-sec`) with a
   section head, and a bare `:first-child` stripped the rule that separates it from the section above. So
   does Clients' "Move clients out of tags" (`#cl-migrate-box`, 7.298.0). */
.ui-tabs__panel > .ui-section-head:first-child { border-top: 0; margin-top: 0; padding-top: 0; }
.ui-section-head__title { font-size: var(--ui-text-md); font-weight: 700; margin: 0 0 var(--ui-space-5); }
.ui-section-head__intro { font-size: var(--ui-text); color: var(--muted); line-height: 1.5; margin: 0 0 14px; }

/* The tight section head (7.428.0): one section of the card's detail, where nine of them stack in one dialog.
   The card's spacing, measured: 14px above the rule, 12px below it, 8px under the label, and the label in the
   brand ink at 600. The standard head's 18/18/12 and 700 would add about 14px to each and restyle every one.

   The ink and the type are on the BOX and the title inherits them. For a head holding only its title that is the
   same thing. SOC 2's card section is the case that is not: it uses the head as the box around its whole section,
   as it did the private rule this replaced, and its file names take the ink from it. Moving the ink onto the
   title alone would restyle those names. */
.ui-section-head--tight { margin: 14px 0 8px; padding-top: 12px; color: var(--brand);
  font-size: var(--ui-text-md); font-weight: 600; }
.ui-section-head--tight .ui-section-head__title { font-size: inherit; font-weight: inherit; margin: 0; }

/* ==========================================================================================
   SPLIT — a narrow side column beside the main one (7.292.0).

     .ui-split
       .ui-split__side   sticky beside the main column; above it, not sticky, on a phone
       .ui-split__main

   The Docs screen's two panes: a contents list beside one document. 232px holds a feature name without
   wrapping. At 760px and below the two stack, and the side stops being sticky, because a sticky list
   that tall would take the screen.
   ========================================================================================== */
.ui-split { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 20px; align-items: start;
  min-width: 0; }
.ui-split__side { position: sticky; top: var(--ui-space-3); max-height: calc(100vh - 120px); overflow-y: auto;
  padding-right: var(--ui-space-1); min-width: 0; }
.ui-split__main { min-width: 0; }
.ui-split[hidden], .ui-split__side[hidden] { display: none; }
@media (max-width: 760px) {
  .ui-split { grid-template-columns: minmax(0, 1fr); gap: var(--ui-space-5); }
  .ui-split__side { position: static; max-height: 40vh; }
}

/* ==========================================================================================
   SIDENAV — a contents list: sections, subheadings, and the pages to open (7.292.0).

     nav.ui-sidenav
       input.ui-sidenav__filter             optional
       .ui-sidenav__section                 an uppercase section label
       .ui-sidenav__sub                     a quieter group inside a section
       button.ui-sidenav__item              [aria-current="page"] is the one showing;
         .ui-sidenav__label                 [data-off] is a page for something switched off here;
         .ui-sidenav__note                  `--indent` sits under a subheading
                                            the small note on the right ("no guide")

   Moved from Docs (`dc-nav*`), whose look it keeps; a test pins it. The selected page is `aria-current`,
   not a class, so it is announced. An item for something switched off is DIMMED, not hidden: "why can I
   not see X" is the question such a page answers, so it has to be reachable. Indent is a modifier on the
   item rather than a sibling selector, because the list is one flat container and `sub ~ item` would
   indent everything after the first subheading.
   ========================================================================================== */
.ui-sidenav { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.ui-sidenav__filter { width: 100%; margin-bottom: var(--ui-space-3); }
.ui-sidenav__section { font-size: var(--ui-text-2xs); font-weight: 700; letter-spacing: .07em; text-transform: uppercase;
  color: var(--muted); margin: var(--ui-space-5) 0 var(--ui-space-1); }
.ui-sidenav__sub { font-size: var(--ui-text-2xs); font-weight: 600; color: var(--muted); opacity: .85;
  margin: var(--ui-space-3) 0 2px var(--ui-space-3); }
.ui-sidenav__item { display: flex; align-items: baseline; justify-content: space-between; gap: var(--ui-space-2);
  width: 100%; text-align: left; background: none; border: 0; border-radius: var(--ui-radius-sm); padding: 5px 8px;
  font: inherit; font-size: var(--ui-text); color: var(--text); cursor: pointer; }
.ui-sidenav__item--indent { padding-left: 16px; }
.ui-sidenav__item:hover { background: color-mix(in srgb, var(--brand) 8%, transparent); }
.ui-sidenav__item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.ui-sidenav__item[aria-current="page"] { background: color-mix(in srgb, var(--brand) 14%, transparent); font-weight: 700; }
.ui-sidenav__item[data-off] .ui-sidenav__label { color: var(--muted); }
.ui-sidenav[hidden], .ui-sidenav__item[hidden] { display: none; }
.ui-sidenav__note { font-size: var(--ui-text-2xs); color: var(--muted); white-space: nowrap; }

/* ==========================================================================================
   PROSE — a rendered document: headings, paragraphs, lists, code, quotes and tables (7.292.0).

   Moved from Docs (`.dc-doc`), whose look it keeps; a test pins it. The column is capped near 68
   characters, which is what makes running text readable rather than decorative. Rendered markdown's
   callers are next: the changelog page and the rendered notes each style the same elements privately.

   **A table in prose scrolls inside itself**, or the whole page scrolls sideways and every paragraph moves
   with it. Found by structure — a `div` whose child is a table — rather than by a renderer's class, so any
   renderer's wrapper works. Tables the screen builds itself are `.ui-table`s, and prose leaves those alone.
   ========================================================================================== */
.ui-prose { min-width: 0; max-width: 68ch; }
.ui-prose h1 { font-size: var(--ui-text-prose-h1); font-weight: 700; margin: 0 0 var(--ui-space-2); overflow-wrap: anywhere; }
.ui-prose h2 { font-size: var(--ui-text-prose-h2); font-weight: 700; margin: 28px 0 var(--ui-space-2); padding-bottom: 5px;
  border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
.ui-prose h3 { font-size: var(--ui-text-md); font-weight: 700; color: var(--brand-bright); margin: 20px 0 var(--ui-space-1); }
.ui-prose p, .ui-prose li { line-height: 1.6; font-size: var(--ui-text-prose); }
.ui-prose ul, .ui-prose ol { padding-left: 20px; margin: var(--ui-space-3) 0; }
.ui-prose code { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: var(--ui-text-code);
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 0 4px; }
.ui-prose blockquote { margin: var(--ui-space-5) 0; padding: 2px 0 2px var(--ui-space-5);
  border-left: 3px solid var(--brand-bright); color: var(--muted); }
.ui-prose div:has(> table:not(.ui-table)) { overflow-x: auto; min-width: 0; max-width: 100%; margin: var(--ui-space-5) 0; }
.ui-prose table:not(.ui-table) { border-collapse: collapse; width: 100%; font-size: var(--ui-text); }
.ui-prose table:not(.ui-table) :is(th, td) { border: 1px solid var(--line); padding: var(--ui-space-2) 9px;
  text-align: left; vertical-align: top; line-height: 1.5; }
.ui-prose table:not(.ui-table) th { background: var(--panel-2); font-weight: 700; }

/* ==========================================================================================
   TEXTBOX — a bounded box of text the screen did not write: a work item's description, its comment history
   (7.492.0).

     .ui-textbox                    the box: the panel-2 fill, the border, the radius; it scrolls inside itself past
                                    `--ui-textbox-max`, which a caller sets; `none` at the root, so no cap by default
     .ui-textbox--rich              rich text in it: 14px at 1.5, paragraph spacing, and a long word or link that
                                    wraps rather than widening the box

   Not `.ui-prose`, which is a document: a 68ch column at 13.5px. A description sits in a card's width and is read at
   the card's size. The comment history keeps its own type; only the box is shared, so one scroll box looks the same
   wherever a card has one.
   ========================================================================================== */
.ui-textbox {
  min-width: 0; padding: var(--ui-space-4) var(--ui-space-5);
  background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--ui-radius);
  max-height: var(--ui-textbox-max); overflow: auto;
}
.ui-textbox--rich { font-size: var(--ui-text-md); line-height: 1.5; color: var(--text); overflow-wrap: anywhere; }
.ui-textbox--rich p { margin: 0 0 var(--ui-space-3); }
.ui-textbox--rich > :first-child { margin-top: 0; }
.ui-textbox--rich :last-child { margin-bottom: 0; }
.ui-textbox[hidden] { display: none; }

/* ==========================================================================================
   ROW LIST — a compact list of things to open: a title, and its facts on the right (7.293.0).

     .ui-rowlist
       .ui-rowlist__item[role="button"][tabindex="0"]
         .ui-rowlist__title
         .ui-rowlist__meta           pills, a figure; kept whole and on the right

   For a list long enough that a card per entry would be a wall of boxes — the management dashboard's
   thirty completed cards, a person's active tasks — at the density those rows had (7px by 10px). An item
   is a `role="button"` with `tabindex="0"`; the app's one delegated handler (app.js) activates any such
   element on Enter and Space, so a caller writes no key handling. The focus ring is the component's.
   ========================================================================================== */
.ui-rowlist { display: flex; flex-direction: column; gap: var(--ui-space-2); min-width: 0; }
.ui-rowlist__item { display: flex; align-items: center; justify-content: space-between; gap: var(--ui-space-3);
  padding: 7px var(--ui-space-4); min-height: var(--ui-target); border: 1px solid var(--line);
  border-radius: var(--ui-radius); cursor: pointer; min-width: 0; }
.ui-rowlist__item:hover { border-color: var(--accent); }
.ui-rowlist__item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ui-rowlist__title { font-size: var(--ui-text); min-width: 0; overflow-wrap: anywhere; }
.ui-rowlist__meta { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap;
  gap: var(--ui-space-3); flex-shrink: 0; }
.ui-rowlist[hidden], .ui-rowlist__item[hidden] { display: none; }

/* ==========================================================================================
   TABS — one of several panels showing at a time, chosen from a row of tabs (7.295.0).

     [role=tablist].ui-tabs
       button[role=tab].ui-tabs__tab[aria-selected]   aria-controls, a roving tabindex
     [role=tabpanel].ui-tabs__panel[data-selected]     shown only when selected

   The WAI-ARIA tabs pattern; the keyboard half — arrows, Home and End, skipping a gated tab — is
   `app/static/tabs.js`, the way `dialog.js` is the dialog's. Moved from Settings (`settings-tab*`), whose
   look it keeps. **A panel is shown by `data-selected`, never by leaving `hidden` off**: `hidden` is the
   feature and permission gating's, so a gated panel stays hidden whichever tab is chosen.

   The bar is sticky with its own background, so a long panel scrolls under it. Its layer of 2 competes only
   with its siblings inside the page's stacking context, which is why it is a literal, not a ladder token.
   ========================================================================================== */
.ui-tabs { display: flex; flex-wrap: wrap; gap: 2px; border-bottom: 1px solid var(--line);
  margin: 0 0 18px; position: sticky; top: 0; background: var(--panel); z-index: 2; min-width: 0; }
.ui-tabs__tab { background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--muted);
  padding: 9px 15px; margin-bottom: -1px; min-height: var(--ui-target); cursor: pointer; font: inherit;
  font-size: var(--ui-text-md); }
/* The hover also sets the wash (7.457.0): without the default's fill, a text-color change alone is too faint a cue (1.7-2.6:1 between rest and hover). */
.ui-tabs__tab:hover { color: var(--text); background: var(--ui-wash); }
.ui-tabs__tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.ui-tabs__tab[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
.ui-tabs__panel:not([data-selected]) { display: none; }
.ui-tabs[hidden], .ui-tabs__tab[hidden], .ui-tabs__panel[hidden] { display: none; }

/* ==========================================================================================
   SUBHEAD — a small heading over one part of a section (7.282.0).

   Smaller than `.ui-section-head__title` and in the brand color, so it groups without starting a new
   section: "Attachments" over a `.ui-files`, "Ticket activity" over a `.ui-thread`. It carries its own size
   rather than inheriting one, so it reads the same inside any panel. Moved from the ticket panel's
   `cd-nj-sub`, whose look it keeps.
   ========================================================================================== */
.ui-subhead { margin: var(--ui-space-4) 0 var(--ui-space-2); font-size: var(--ui-text); font-weight: 600;
  color: var(--brand); }

/* ==========================================================================================
   THREAD — a list of dated entries: who and when, then what (7.282.0).

     ol|ul.ui-thread
       li.ui-thread__entry[data-new]    `data-new`: arrived since the reader last had it; the accent edge
         .ui-thread__meta               author · time, and any labels (`.ui-pill`s)
           .ui-thread__figure           optional: the entry's own figure, first in the meta line — "18 mi"
         .ui-thread__lead               optional: what an entry that is not a comment is — "Status → Resolved"
         .ui-thread__body               the text, line breaks kept as typed

   Moved from the ticket panel (`cd-nj-list` / `cd-nj-u`, 7.12.2), whose look it keeps; the card modal's
   comment history is the same shape and is its next caller. New-since is the one thing that gets an
   accent, because it is what was being missed while the dashboard held a copy. The body is `pre-wrap`:
   ticket and comment text arrives flattened to plain text with the requester's own line breaks.
   ========================================================================================== */
.ui-thread { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column;
  gap: var(--ui-space-3); font-size: var(--ui-text); min-width: 0; }
.ui-thread__entry { padding: var(--ui-space-3) var(--ui-space-4); border-radius: var(--ui-radius);
  background: var(--panel-2); border: 1px solid var(--line); min-width: 0; }
.ui-thread__entry[data-new] { border-left: 3px solid var(--brand-bright); }
/* `data-accent` (7.419.0): an entry meant to stand out from the others, for what it IS rather than when it came: a
   card's management comment. The same bar as `data-new`, deliberately a separate attribute, because "a lead wrote
   this to direct the work" and "this arrived since you last looked" are different facts and an entry can be both. */
.ui-thread__entry[data-accent] { border-left: 3px solid var(--brand-bright); }
/* `--read` (7.419.0): a thread read in full, at the reading size of the surface it sits in rather than the
   component's compact 13px: a card's comments, at the card's 15px. Measured cramped at 13px. */
.ui-thread--read { font-size: inherit; }
.ui-thread__meta { display: flex; align-items: center; gap: var(--ui-space-2); flex-wrap: wrap;
  color: var(--muted); font-size: var(--ui-text-sm); }
/* An entry that IS a number (a visit's miles, 7.393.0) leads its meta line with it, at body size and in the
   text color, so the figure the row exists for is not muted along with who and when. */
.ui-thread__figure { color: var(--text); font-size: var(--ui-text); font-weight: 700;
  font-variant-numeric: tabular-nums; }
.ui-thread__lead { margin-top: var(--ui-space-1); font-weight: 600; }
.ui-thread__body { margin-top: var(--ui-space-1); white-space: pre-wrap; overflow-wrap: anywhere; }
.ui-thread[hidden], .ui-thread__entry[hidden] { display: none; }

/* ==========================================================================================
   DROPZONE — a box one file is dropped on or clicked to choose, showing the file once there is one (7.376.0).

     .ui-dropzone.ui-files__drop        the box: a button (`role="button"`, `tabindex="0"`), keyboard-reachable
       img.ui-dropzone__thumb           what is there now; hidden until there is something
       .ui-dropzone__text               what to do: "Drop an image here, or click to choose"

   Moved from Branding's three image pickers (`br-drop`, 6.87.0), and pinned to their look value by value: the
   resting box, its hover and keyboard focus are unchanged. **The drag state is the FILES one**,
   `.ui-files__drop[data-over]`, so a file dragged over a picker looks as it does over a card's Files. Until
   7.376.0 the picker had its own (an accent border on a darker panel); one drag state across the app is the
   point, and it only shows mid-drag. This section sits before FILES so that rule, equal in specificity to the
   focus one here, wins while something is dragged over a focused box. */
.ui-dropzone { display: flex; align-items: center; justify-content: center; gap: var(--ui-space-3); min-height: 104px;
  padding: var(--ui-space-4); border: 1px dashed var(--line); border-radius: var(--ui-radius); background: var(--panel);
  cursor: pointer; text-align: center; }
.ui-dropzone:hover, .ui-dropzone:focus-visible { border-color: var(--accent); outline: none; }
.ui-dropzone__text { font-size: var(--ui-text-sm); color: var(--muted); }
.ui-dropzone__thumb { display: block; max-width: 100%; max-height: 84px; object-fit: contain; }
.ui-dropzone[hidden], .ui-dropzone__thumb[hidden] { display: none; }

/* ==========================================================================================
   FILES — a grid of attached files: pictures as thumbnails, everything else as a row (7.280.0).

     .ui-files__drop[data-over]         optional: the area a file can be dropped on
       .ui-files                        the grid; wraps
         figure.ui-files__item          a picture: __thumb inside a link, then a figcaption
         .ui-files__item--row           a file: __kind, __name (the link), optional __alt, __meta

   Moved from the card's Files section and the ticket panel, which drew the same grid under private
   `cd-att-*` names (7.14.0, 7.15.0), and it kept their look to the pixel: 7.280.0 pinned every rule against
   the old ones, value by value, until 7.282.0 moved the ticket panel over and retired them.
   A grid rather than a list because these are mostly screenshots, and the thing someone wants is to
   recognize one at a glance. A row is not a failed thumbnail: it is the right shape for a .log or a
   .pdf, and the `__kind` word is what makes a dozen of them scannable.

   **Which shape a file gets is the caller's decision, not the stylesheet's**: the card decides from a
   content type the server states, the ticket panel from an allowlist. The drop target's state is an
   attribute (`data-over`) rather than a class, so no caller invents a word for it. */
.ui-files { display: flex; flex-wrap: wrap; gap: var(--ui-space-4); margin: 0 0 var(--ui-space-1); min-width: 0; }
.ui-files__item { margin: 0; max-width: 190px; min-width: 0; }
.ui-files__thumb { display: block; width: 100%; max-height: 130px; object-fit: cover;
  border-radius: var(--ui-radius); border: 1px solid var(--line); background: var(--panel-2); }
.ui-files__item figcaption { margin-top: var(--ui-space-1); font-size: var(--ui-text-sm); overflow-wrap: anywhere; }
/* `flex-wrap` (7.282.0): on a phone the name, the second link and the meta did not fit one line, and the
   name — allowed to break anywhere, for a pasted filename with no spaces — broke mid-word ("Downlo / ad /
   image"). Wrapping lets the meta move to its own line first. */
.ui-files__item--row { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--ui-space-3); max-width: none;
  padding: var(--ui-space-2) var(--ui-space-4); border-radius: var(--ui-radius); background: var(--panel-2);
  border: 1px solid var(--line); }
.ui-files__kind { flex: none; padding: 1px 7px; border-radius: var(--ui-radius-pill); font-size: var(--ui-text-xs);
  text-transform: uppercase; letter-spacing: .04em; color: var(--brand);
  background: var(--panel); border: 1px solid var(--line); }
.ui-files__name { font-weight: 600; overflow-wrap: anywhere; }
.ui-files__meta { display: block; color: var(--muted); font-size: var(--ui-text-sm); }
/* A second, quieter link on a row — the ticket panel's link to the ticketing console beside a file whose bytes it could
   not fetch (7.282.0). Secondary weight and color, because clicking the NAME is the action that usually works. */
.ui-files__alt { flex: none; font-size: var(--ui-text-sm); color: var(--muted); }
.ui-files__item--row .ui-files__meta { display: inline; }
.ui-files__drop[data-over] { outline: 2px dashed var(--muted); outline-offset: 4px; border-radius: var(--ui-radius-sm); }
.ui-files[hidden], .ui-files__item[hidden] { display: none; }

/* ==========================================================================================
   FIGURE — an embedded document, with its caption outside it.

   For an image served from its own route: a diagram, a chart, a rendered SVG. The caption sits on
   the app's surface in themed ink, NOT inside the artifact, so it stays selectable text that
   translates, reflows and responds to the theme while the artifact itself stays byte-identical
   wherever it is saved or pasted.

   **The border is load-bearing, not decoration.** A diagram that paints its own white paper — which
   is what a standalone SVG under a sandbox CSP must do, since it cannot reach `theme.css` — is
   invisible against `--panel` in light mode. Paper on paper. The border is what makes it read as an
   embedded object in both themes.

   Measured, and the reason a diagram paints its own paper at all: NO color reaches 4.5:1 against
   both the light and the dark surfaces. It would need luminance <= 0.1833 against `#ffffff` and
   >= 0.2501 against `--panel-2`; the window is negative. 3:1 is achievable, so a single palette can
   carry status as a SHAPE but never as colored TEXT. Giving the artifact its own ground removes the
   constraint instead of satisfying it — against white the status tokens score 5.49 / 5.54 / 5.44.
   ========================================================================================== */
.ui-figure { display: flex; flex-direction: column; gap: var(--ui-space-2); margin: 0; min-width: 0; }

.ui-figure__frame {
  display: block; overflow: auto;
  border: 1px solid var(--line);
  border-radius: var(--ui-radius);
  background: var(--panel-2);
  min-width: 0;
}
/* `max-width` and not `width`: an artifact narrower than the frame keeps its size rather than being
   stretched to fit, which for a diagram is a distortion of the thing it is depicting. */
.ui-figure__frame > img, .ui-figure__frame > svg, .ui-figure__frame > object {
  display: block; max-width: 100%; height: auto;
}
/* `--scroll`: a wide artifact at its natural size, scrolled inside the frame rather than scaled down to
   it (7.272.1). Scaling is right for a compact diagram and wrong for a long one: a 2,700px path diagram
   shrunk to a 900px column is a picture of unreadable text. The frame already scrolls; this lifts the
   cap. The artifact needs an intrinsic width (an image's own, or an SVG's `width` attribute), or there is
   nothing to keep.

   A region that scrolls must be reachable without a mouse, so its markup carries `tabindex="0"`,
   `role="region"` and an `aria-label` naming what is inside — the gallery shows it, and a test pins it.
   Not every browser makes a scroller focusable on its own, and one that is not can be scrolled by
   nobody who navigates with a keyboard. The focus ring is the frame's, drawn outside its border. */
.ui-figure__frame--scroll > img, .ui-figure__frame--scroll > svg, .ui-figure__frame--scroll > object {
  max-width: none;
}
.ui-figure__frame:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.ui-figure__caption { font-size: var(--ui-text-sm); color: var(--muted); }

/* ==========================================================================================
   DISCLOSURE — a row that expands to its detail.

   Native `<details>`/`<summary>`, for the reason 7.155.0 gave the changelog page: collapsed is the
   default by construction rather than by script, it survives a reload because there is no state to
   survive, and keyboard and screen-reader behavior come with the element. One level of nesting is
   what this styles; arbitrary depth is a tree, which is a different component.

   `--compact` (7.492.0) is the work item's own: the board card's Description and Comment history, the card modal's
   Description and the inactive card's. They were two private copies of it.
   ========================================================================================== */
.ui-disclosure { border-bottom: 1px solid var(--line); min-width: 0; }

.ui-disclosure__row {
  display: flex; align-items: center; gap: var(--ui-space-3);
  padding: var(--ui-space-3) var(--ui-space-4);
  min-height: var(--ui-target);
  cursor: pointer;
  font-size: var(--ui-text);
  list-style: none;
}
.ui-disclosure__row::-webkit-details-marker { display: none; }
.ui-disclosure__row:hover { background: var(--ui-wash); }
.ui-disclosure__row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* The caret is drawn by the component so a caller never ships its own glyph, and it is
   `aria-hidden` by construction: `<summary>` already announces its expanded state, so a second
   announcement of the same fact is noise. */
.ui-disclosure__row::before {
  content: '\25b8'; flex: 0 0 auto; font-size: var(--ui-text-xs); color: var(--muted);
  transition: transform .12s ease;
}
.ui-disclosure[open] > .ui-disclosure__row::before { transform: rotate(90deg); }

.ui-disclosure__label { flex: 1 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ui-disclosure__body { padding: var(--ui-space-3) var(--ui-space-4) var(--ui-space-5);
  font-size: var(--ui-text-sm); }
/* A link placed directly in a body is a destination of its own rather than a word in a sentence, so it
   gets the target floor every other control has (7.260.0: Company Links' service links measured 18px
   tall without it). A long label wraps inside the body instead of running past the popover. */
.ui-disclosure__body > a { display: flex; align-items: center; min-height: var(--ui-target);
  overflow-wrap: anywhere; }
/* `--compact` (7.492.0): a section of a work item, opened from a short brand-colored line rather than a bordered row.
   No rule under it and no padding, so the summary's words sit at the card's content edge where they always did; the
   row stays full width, so the whole line is the click target, and it takes the hover wash. What it opens is usually
   a `.ui-textbox`, 8px under it. */
.ui-disclosure--compact { border-bottom: 0; }
.ui-disclosure--compact > .ui-disclosure__row {
  padding: 0; gap: var(--ui-space-2); border-radius: var(--ui-radius-sm);
  font-weight: 600; color: var(--brand);
}
.ui-disclosure--compact > .ui-disclosure__row::before { font-size: var(--ui-text-2xs); }
.ui-disclosure--compact > .ui-textbox { margin-top: var(--ui-space-3); }
/* Inside a panel, the panel's own edge is the last separator; a second line one pixel above it reads
   as a rendering fault. Out of a panel, the last row keeps its line so a list still ends. */
.ui-panel > .ui-disclosure:last-child { border-bottom: 0; }

/* The hidden-attribute rule again, for the three added in 7.203.0. Each of these sets a `display`, so
   without this `el.hidden = true` leaves it on screen — the defect that has bitten `.sow-f`,
   `.cl-services`, `.it-parent-note` and `.busy-bar` independently. */
.ui-card-grid[hidden], .ui-card[hidden], .ui-figure[hidden], .ui-disclosure[hidden] { display: none; }

/* ==========================================================================================
   DIALOG — `.ui-dialog`, one modal layer over the page (7.255.0).

   The app's modals were each written privately (`.overlay` / `.modal` in `cards.css`); this is the
   shared one, first used by the WAF page's gateway tiles. Structure:

     .ui-dialog[hidden]            the fixed layer; its own [hidden] reassert below
       .ui-dialog__scrim           the dim; a click on it closes (the script's job, not CSS's)
       .ui-dialog__box[role=dialog][aria-modal=true]
         .ui-dialog__head          title + the close control (a `.ui-btn--icon`)
         .ui-dialog__body          scrolls; the head stays put

   **One layer.** It sits at `--z-overlay`, the same rung as every other modal, so nothing a page opens
   from inside it can end up underneath. A page keeps ONE dialog element and fills it per opening,
   rather than one hidden dialog per tile, so two cannot be open at once.

   **Three modifiers (7.431.0, 7.436.0), all off by default.** `--narrow` caps the box at 680px, for a form or a
   short read that the 880px box leaves sprawling; `--small` at 560px, for a question with a field or two. `--stacked` is a dialog opened from inside another: it sits on
   `--z-top`, the rung the legacy `.overlay-top` used for the same job, and dialog.js opens it without closing
   the one beneath. On a phone both still take the screen.
   ========================================================================================== */
.ui-dialog {
  position: fixed; inset: 0; z-index: var(--z-overlay);
  display: flex; align-items: center; justify-content: center;
  padding: var(--ui-space-6);
}
.ui-dialog__scrim { position: absolute; inset: 0; background: var(--scrim); }
.ui-dialog__box {
  position: relative;
  width: min(880px, 100%); max-height: 86vh;
  display: flex; flex-direction: column; overflow: hidden;
  background: var(--panel); color: var(--text);
  border: 1px solid var(--line); border-radius: var(--ui-radius-lg); box-shadow: var(--shadow);
}
.ui-dialog__head {
  display: flex; align-items: flex-start; justify-content: space-between; gap: var(--ui-space-5);
  padding: var(--ui-space-5) var(--ui-space-6); border-bottom: 1px solid var(--line);
}
.ui-dialog__title { margin: 0; font-size: var(--ui-text-lg); font-weight: 600; min-width: 0;
  overflow-wrap: anywhere; }
/* The close × is a glyph at the 24px, regular weight, of the private modals' × it replaced (7.444.0): at the small
   button's 12px/600 it read as a speck beside a dialog's title. Only the glyph grows; the button keeps its box, so
   the head does not. Scoped to the head, so no other icon button changes. */
.ui-dialog__head > .ui-btn--icon { font-size: var(--ui-text-glyph); font-weight: 400; }
/* A guarded dialog's question (7.472.0), set between the head and the body by dialog.js while it asks, so it stays in
   view however far the form is scrolled. Inset to the body's own margins. */
.ui-dialog__box > [data-dialog-confirm] { margin: var(--ui-space-4) var(--ui-space-6) 0; }
.ui-dialog__body { overflow: auto; padding: var(--ui-space-5) var(--ui-space-6) var(--ui-space-6); }
/* A dialog's own action row, when it closes the body (7.453.0): set off from the fields above by the body's padding.
   A form laid out in a `.ui-stack` (the bug report's) gets this from the stack's gap; a form written straight into the
   body (the new-card form, the calendar's promote and new event) does not, and its buttons sat flush on the last field. */
.ui-dialog__body > .ui-btn-row:last-child { margin-top: var(--ui-space-6); }
/* On a phone the box takes the screen: a centered card with a 16px gutter leaves a table nowhere to go. */
@media (max-width: 640px) {
  .ui-dialog { padding: 0; }
  .ui-dialog__box { width: 100%; height: 100%; max-height: none; border-radius: 0; border: 0; }
}
.ui-dialog[hidden] { display: none; }
/* After the phone rule on purpose: `min(680px, 100%)` is the full width there anyway, so the narrow box still
   takes a phone's screen, as every dialog does. */
.ui-dialog--narrow .ui-dialog__box { width: min(680px, 100%); }
/* A question with a field or two (7.436.0): 560px, the width the confirmations that ask one thing had. */
.ui-dialog--small .ui-dialog__box { width: min(560px, 100%); }
.ui-dialog--stacked { z-index: var(--z-top); }

/* ==========================================================================================
   TOAST — `.ui-toast`, a message that comes and goes on its own (7.459.0).

     .ui-toasts                      the stack, fixed bottom-right; the page holds one (#reminder-toasts)
       .ui-toast[data-tone]          one message; tone `warn` or `err` colors its stripe, absent is neutral
         .ui-toast__head             the title and the close ×
           .ui-toast__title
           button.ui-btn--ghost.ui-btn--sm.ui-btn--icon   the ×, type="button" aria-label="Dismiss"
         .ui-toast__body             optional, muted

   Two callers: core's `flashToast` (toast.js) and the calendar's meeting reminder. The × is a 24×24 box, which meets
   the target floor without the icon button's 32px height growing every toast, at the 15px regular weight of the
   glyph it replaced. Its own keyframe, because `toastin` in cards.css is the busy indicator's too.
   ========================================================================================== */
.ui-toasts {
  position: fixed; right: var(--ui-space-6); bottom: var(--ui-space-6); z-index: var(--z-overlay);
  display: flex; flex-direction: column; gap: var(--ui-space-4); max-width: min(360px, 90vw);
}
.ui-toast {
  background: var(--panel); color: var(--text);
  border: 1px solid var(--line); border-left: 3px solid var(--brand-bright); border-radius: var(--ui-radius-lg);
  padding: var(--ui-space-5) var(--ui-space-6); box-shadow: var(--shadow-toast);
  animation: ui-toast-in .18s ease-out;
}
.ui-toast[data-tone="warn"] { border-left-color: var(--warn); }
.ui-toast[data-tone="err"] { border-left-color: var(--err); }
.ui-toast__head { display: flex; align-items: center; gap: var(--ui-space-3); }
.ui-toast__title { flex: 1; min-width: 0; font-size: var(--ui-text-lg); font-weight: 600; color: var(--text); }
.ui-toast__head > .ui-btn--icon {
  width: var(--ui-target); height: var(--ui-target); min-height: var(--ui-target);
  font-size: var(--ui-text-lg); font-weight: 400;
}
.ui-toast__body { margin-top: var(--ui-space-1); font-size: var(--ui-text); color: var(--muted); }
.ui-toasts[hidden], .ui-toast[hidden], .ui-toast__head[hidden] { display: none; }
@keyframes ui-toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
